Building This Website
I wanted somewhere to put things.
Not just code, but the random thoughts, experiments, half-working ideas, debugging sessions, and those moments where something finally works and you just sit there thinking, “wait, why did that actually work?”
GitHub is great for code, but it doesn't really tell the story behind it. Twitter is way too temporary. Notion is where unfinished ideas go to quietly die.
So naturally, instead of using one of the hundreds of websites that already solve this problem, I decided to build my own.
Because apparently that's easier.
The idea
The idea actually came to me at around 2 AM while I was debugging a particularly stupid TypeScript error.
You know the kind. Everything looks fine, nothing looks fine, and after staring at the same five lines for way too long, you start questioning every decision that led you to this point.
Eventually I found the problem.
I was importing something from the wrong barrel file.
That's it.
But it got me thinking. I spend a ridiculous amount of time solving little problems like this, trying things, breaking things, fixing them, and then immediately forgetting how I fixed them.
I wanted somewhere to keep that stuff.
Three days later, I had a Next.js project.
About two weeks after that, this website was actually online.
Why build it myself?
There are already plenty of options.
Ghost exists. Substack exists. Hashnode exists. WordPress has been doing this forever.
And honestly, they're probably all more mature than whatever I could throw together over a couple of weekends.
But I didn't really want a ready-made solution.
I wanted to decide how everything worked.
I wanted the weird little features that probably wouldn't make sense in someone else's product. I wanted to control the UI, the data, the components, the themes, the music player, all of it.
There's also something I really like about building things from scratch. You understand the thing differently when you've had to build the annoying parts yourself.
Is this website objectively a better solution than using an existing platform?
Probably not.
Was building it more fun?
Absolutely.
And there's something weirdly satisfying about opening ishpiked.dev, or localhost:3000 when I'm developing, and knowing that every part of it exists because I put it there.
No mystery. No giant CMS hiding somewhere. Just code.
What's actually in here?
At its core, it's a blog.
The posts are stored as TypeScript objects and rendered as React components, with static generation handling the pages at build time.
There's also a music player sitting at the bottom of the screen.
It stays alive while navigating between pages, because apparently I can't code without music playing in the background.
There's an AI chat bot too.
It's not just some generic chatbot dropped onto the site. It has context about the actual codebase, so you can ask it things like how the music player works, why I used the App Router, or how the theme system is put together.
Then there's the theme system itself.
Light mode, dark mode, CSS variables, localStorage, and no annoying flash of the wrong theme when the page loads.
And finally, there's no CMS.
The posts live directly in
src/lib/posts.ts as TypeScript objects. That means they're version controlled, type checked, easy to edit, and I don't have to maintain another service just to write a post.The stack
The stack is intentionally pretty boring.
Next.js 15
React 19
TypeScript 5.6
And a handful of normal development dependencies.
package.json
// package.json highlights
{
"dependencies": {
"next": "15.0.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"typescript": "5.6.0"
},
"devDependencies": {
"@types/react": "19.0.0",
"@types/node": "22.0.0",
"eslint": "9.0.0",
"postcss": "8.4.0",
"autoprefixer": "10.4.0"
}
}That's basically it.
I didn't use Tailwind. I didn't add a UI library. I didn't install some enormous design system just to style a button.
Most of the styling is just CSS custom properties and a few utility classes I wrote myself.
For the themes, everything revolves around CSS variables.
The light theme defines the normal values, and the dark theme overrides them when
data-theme="dark" is present on the HTML element.That's pretty much the whole trick.
No ThemeProvider. No context. No complicated state management.
Just an attribute and some CSS.
src/app/globals.css
/* src/app/globals.css */
:root {
--bg: #ffffff;
--fg: #0f0f0f;
--muted: #737373;
--dim: #e5e5e5;
--accent: #2563eb;
}
[data-theme='dark'] {
--bg: #0a0a0a;
--fg: #fafafa;
--muted: #a3a3a3;
--dim: #262626;
--accent: #60a5fa;
}
.themeable {
color: var(--fg);
background-color: var(--bg);
}
* {
border-color: var(--dim);
}The browser does most of the work.
The theme system
The theme code is probably one of the simplest parts of the entire project.
When the site loads, it checks whether I've already saved a theme preference.
If I haven't, it checks the system preference.
Then it puts the result on the
<html> element.Switching themes is basically the same thing in reverse. Read the current value, flip it, and save the new one to localStorage.
src/lib/theme.ts
// src/lib/theme.ts
export function initTheme() {
if (typeof window === 'undefined') return
const stored = localStorage.getItem('theme')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const initial = stored ?? (prefersDark ? 'dark' : 'light')
document.documentElement.setAttribute('data-theme', initial)
}
export function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme')
const next = current === 'dark' ? 'light' : 'dark'
document.documentElement.setAttribute('data-theme', next)
localStorage.setItem('theme', next)
}That's it.
It's not particularly revolutionary, but I like it because there isn't much to go wrong.
The music player
This was probably one of the more interesting parts.
I wanted the music player to stay alive while moving around the site.
Normally, navigating between pages can cause components to unmount and mount again, which isn't exactly ideal when the component is playing audio.
The solution ended up being pretty clean with the Next.js App Router.
The player lives inside a route group layout called
(with-player).That layout wraps the pages that need the persistent player, so the player doesn't get destroyed every time the page content changes.
src/app/(with-player)/layout.tsx
// src/app/(with-player)/layout.tsx
import { MusicPlayer } from '@/components/MusicPlayer'
export default function WithPlayerLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
{children}
<MusicPlayer />
</>
)
}The actual player is a client component.
It keeps a reference to the audio element, tracks the current playback time and duration, and updates the UI as the song plays.
There's no giant audio framework behind it.
It's basically an
Audio object, a few React hooks, and some buttons.Sometimes the simple solution is the good one.
src/components/MusicPlayer.tsx
// src/components/MusicPlayer.tsx
'use client'
import { useState, useRef, useEffect } from 'react'
export function MusicPlayer() {
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const audioRef = useRef<HTMLAudioElement>(null)
useEffect(() => {
const audio = new Audio('/music/track.mp3')
audio.loop = true
audioRef.current = audio
audio.addEventListener('timeupdate', () => {
setCurrentTime(audio.currentTime)
})
audio.addEventListener('loadedmetadata', () => {
setDuration(audio.duration)
})
return () => audio.pause()
}, [])
const toggle = () => {
if (!audioRef.current) return
isPlaying ? audioRef.current.pause() : audioRef.current.play()
setIsPlaying(!isPlaying)
}
return (
<div className="fixed bottom-0 left-0 right-0 border-t border-[var(--dim)] bg-[var(--bg)] p-4">
<button onClick={toggle} className="flex items-center gap-3">
{isPlaying ? (
<svg className="size-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
) : (
<svg className="size-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
)}
<div className="flex-1">
<div className="text-xs font-medium text-[var(--fg)]">Now Playing</div>
<div className="text-xs text-[var(--muted)]">Midnight Debug Session</div>
<input
type="range"
min={0}
max={duration}
value={currentTime}
onChange={(e) => {
if (audioRef.current) audioRef.current.currentTime = +e.target.value
}}
className="w-full mt-1 accent-[var(--accent)]"
/>
</div>
</button>
</div>
)
}The chat bot
The chatbot uses the Vercel AI SDK with a custom system prompt.
The interesting part isn't really the API call itself.
It's the context.
During the build process, I generate a string containing the important parts of the codebase. Things like the theme system, music player, post structure, and some of the component patterns.
That gets included in the system prompt.
So when you ask the bot how something works, it isn't guessing based on some generic Next.js tutorial.
It actually has information about this project.
The API route then uses
streamText to stream the response back to the frontend.It's surprisingly little code for something that feels pretty nice to use.
src/app/api/chat/route.ts
// src/app/api/chat/route.ts
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
const SYSTEM_PROMPT = `
You are an AI assistant for ishpiked.dev.
You have full knowledge of this codebase:
${CODEBASE_CONTEXT}
Answer questions about the implementation, architecture,
and design decisions. Be concise but thorough.
`
export async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: openai('gpt-4o-mini'),
system: SYSTEM_PROMPT,
messages,
temperature: 0.3,
})
return result.toDataStreamResponse()
}Things I learned
The App Router took me a little while to get comfortable with.
The whole Server Components by default thing changes how you think about React. Once it clicked, though, it started making a lot more sense.
I also ended up liking CSS variables way more than I expected.
For a small project like this, they're more than enough for theming. There's no need to introduce another layer just to switch a few colors.
Using TypeScript as the content format was another nice surprise.
Having posts as typed objects means I get autocomplete, compiler errors, refactoring support, and none of the weird edge cases that come with parsing markdown frontmatter.
The route group idea was probably my favorite discovery.
Using
(with-player) to keep the music player alive without dragging a context provider through the entire application was much cleaner than some of the other approaches I considered.And the AI SDK makes streaming responses ridiculously straightforward.
A few lines of code and suddenly you have a proper streaming chat interface.
What's next?
There's still a bunch of stuff I want to add.
An RSS feed is probably next, because apparently there are still people out there using feed readers.
I also want proper syntax highlighting for code blocks, most likely with Shiki.
Search would be useful too. I haven't decided whether I want to keep that completely client-side with a tiny index or use something like Meilisearch.
Post images could use proper Next.js image optimization as well.
And, obviously, more posts.
This is the first one.
If you actually made it this far, thanks for reading.
This website is probably going to change a lot.
Things will break. Things will get rewritten. I'll probably decide that some part of the architecture is terrible and replace it six months from now.
That's kind of the point.
The code is available on GitHub if you want to look around, steal an idea, or tell me I did something stupid.
And now, if you'll excuse me, I have another side project to start.