I dockerized this portfolio back in April and was pretty happy with it. Two containers, GitHub Actions, push to main, done.
Then I started reading anishshobithps.com's source code. And I couldn't stop.
Static blog generation. Dynamic OG images. A TOC that tracks your scroll position. A scrollbar that's somehow better than the default one. JSON-LD structured data on every page. Posts that build in milliseconds instead of seconds.
My blog was rendering markdown per request with a custom unified/Shiki pipeline and a disk cache duct-taped on top because first-load times were hitting 8 seconds. My fonts were @import url(googleapis.com). My API calls were NEXT_PUBLIC_API_URL env vars pointing at an Express server.
It worked. It just wasn't good.
So on 2026-06-05 I did the thing you're not supposed to do. I migrated the entire site from React + Vite + React Router to Next.js 16, in place, in the same repo, while also switching package managers and rewriting the blog system from scratch.
81 commits later, here's what actually happened.
The Big Swaps
- React + Vite + React Router, replaced with Next.js 16 (App Router, Turbopack)
- npm, replaced with Bun (
bun --bun next dev/build/start) - Custom unified/Shiki markdown pipeline, replaced with Fumadocs
vite.config.ts,App.tsx,main.tsx,index.htmlall deleteduseNavigate/useLocationswapped foruseRouter/usePathname/postsrenamed to/blogseverywhere@import url(googleapis.com)fonts swapped fornext/font/google
Most of this was mechanical. Find and replace, fix the type errors, move on. The interesting parts were the things that looked like they'd be easy and weren't.
Fumadocs: From 8 Seconds to Static HTML
This was the actual reason I did all of this.
Before, every blog post request ran through a custom pipeline: remark, rehype-highlight, rehype-stringify, with a .post-cache/ directory on disk so Shiki didn't re-highlight the same post on every request. First load of a post: ~8 seconds. Cached load: still noticeably slow.
Fumadocs changes the entire model. Posts are .mdx files in content/blog/, compiled at build time, and served as static HTML.
postinstall: "fumadocs-mdx"That one line in package.json regenerates .source/, the typed content layer Fumadocs builds from your MDX files, every time you install. The blog route became [...slug], compatible with source.generateParams(), and all 18 posts now get generateStaticParams()'d at build time.
Result: first post load went from ~8s to ~700ms. Every load after that is instant, because it's just static HTML.
I also wrote a custom remarkReadingTime plugin because the existing reading-time libraries count code blocks as prose, which makes a post with a 200-line config snippet say "47 min read." Mine walks the AST and only counts actual prose nodes. 260 wpm. Much more honest numbers now.
The lastModified plugin was the other nice addition. It reads git history per file and shows "Updated [date]" under the publish date if the file changed since. Sounds simple. It was not simple. More on that below.
Converting 18 Posts by Hand
All 18 posts went from plain .md to .mdx with YAML frontmatter, moved into content/blog/. Mostly mechanical: add frontmatter, fix any raw HTML that needed to become JSX-compatible. But doing it for 18 files one at a time is the kind of task where you start questioning your life choices around post 11.
The leftover content/*.md originals are still sitting there, unused, because by the time I noticed I just wanted to ship. They're on the cleanup list.
Dynamic OG Images (This One Was Actually Fun)
src/app/og/route.tsx. Edge runtime, next/og's ImageResponse, zero extra dependencies. Generates a 1200x630 OG image on the fly using the site's actual palette: taupe background (#2e2a27), diagonal stripe texture, soft-royal-blue accents.
?title=...&description=...&path=...&tags=comma,separatedEvery blog post now gets its own OG image with its title, tags, and the site's branding baked in, generated at request time at the edge. You can preview any combination locally:
http://localhost:3000/og?title=My+Post&tags=nextjs,typescript&path=blogsThis replaced... nothing, actually. There was no OG image system before. Just a happy addition.
The Footer Rewrite (a.k.a. The Single-Page Layout)
Somewhere in the middle of all this I decided the homepage needed to be a single long-scroll page instead of separate routes. Hero, About, Projects, Now, Writing, Contact, all anchor-linked.
This meant /about, /projects, and /contact became redirects to /#about etc. Old links still work, they just bounce.
It also meant building a real Footer.tsx for the first time. Every "fun but not hiring-relevant" widget (Spotify now-playing, WakaTime stats, GitHub contributions graph, visitor counter, socials, theme toggle) moved out of the Hero section and into the footer. The Hero is now just an intro. Name, tagline, two buttons. Clean.
I also pulled a bunch of small UI patterns from anish's site during this: a SectionDivider/SectionLabel component used across every section, Mark highlights (the soft-royal-blue text highlighting you've probably noticed), a custom floating-pill scrollbar, and a fused button-group pattern for the blog filters/pagination. None of it copy-pasted, all reimplemented with this site's own palette and component structure, but the patterns are directly inspired by his.
The API Routes Move
Express routes became Next.js API routes: /api/now-playing, /api/wakatime-daily, /api/wakatime-languages, /api/github-contributions, /api/views, /api/views/[slug]. All NEXT_PUBLIC_API_URL references gone, everything's a relative /api/... path now.
Express isn't dead though. It's still running, just for one job: Spotify OAuth. The /login and /callback redirect dance needs a stateful session, and rebuilding that in Next.js wasn't worth it for one feature.
The Thing That Actually Broke: Docker + Git History
Everything above was mostly tedious but predictable. The one thing that genuinely ate hours was making the lastModified plugin work in production.
Locally, it worked instantly. The plugin reads git log for each content file and shows "Updated [date]" if it differs from the publish date. Cool feature, took like 10 minutes to wire up.
Then I deployed it. And the "Updated" date just... never showed up. No error. No crash. The build succeeded. The site looked fine. The feature just silently did nothing.
Three separate things were wrong, stacked on top of each other.
1. The build order was backwards.
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY . .bun install runs postinstall, which runs fumadocs-mdx codegen, before the source files are even copied in. So it was generating .source/ against basically nothing. Fix:
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile --ignore-scripts
COPY . .
RUN bun run postinstall2. .git was in .dockerignore.
Even after fixing the build order, the dates still didn't show. Because .dockerignore had .git in it, which is standard practice, you don't usually need git history inside a container. Except the lastModified plugin's entire job is reading git history. No .git directory, no history, plugin fails silently and just doesn't render the "Updated" line.
node_modules
dist
-.git
.github3. The base image doesn't have git.
oven/bun:1, the official Bun image, doesn't ship with git. So even with .git copied in, there was nothing to run git log with.
FROM oven/bun:1 AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y git --no-install-recommends && rm -rf /var/lib/apt/lists/*Three completely silent, completely unrelated-looking failures, all required to fix before a single "Updated Jun 5" label would render correctly. Each one on its own would've been a five-minute fix if I'd known what to look for. Finding all three, with zero error messages and a build that "succeeds" the whole time, is what actually took the hours.
This is the classic works-on-my-machine trap, except worse. It's not "doesn't work in prod." It's "works in prod but quietly does less than it should, and nothing tells you."
What's Still Left
- Delete the leftover
content/*.mdoriginals Guestbook / comments, needs Neon (Postgres) + Drizzle + Clerk auth, haven't started— done, see below- Typing animation on the hero, cursor trail effect, low priority vibes-only features
- Dynamic project fetching from GitHub API instead of the static
projects.json - Dual favicons for light/dark mode
- A
/blog/:path*.mdxroute so LLMs can fetch raw markdown directly
Was It Worth It
Yeah. The blog went from an embarrassingly slow first load to static HTML served instantly. The OG images, JSON-LD, sitemap, and TOC are things I genuinely wanted and now have. The single-page layout feels much more like a portfolio and less like a documentation site.
But also, I migrated a production site's entire framework, package manager, and content pipeline simultaneously, in place, on a Friday. If the git-history bug had been something more fundamental than a Dockerfile ordering issue, this could've gone very differently.
It didn't. It's live. And now every post on this blog has an "Updated" date that's actually accurate, because of three lines spread across a Dockerfile and a .dockerignore that took way longer to find than they should have.
Part 2: Comments, Guestbook, and Actually Thinking About Security
A few weeks after the migration settled, I went back to that "what's still left" list. The guestbook and comments line had been sitting there guilt-tripping me. So I built them.
The stack was already decided: Clerk for auth, Neon for the database, Drizzle as the ORM. Same as the reference implementation I'd been reading, same as what made sense for a Next.js 16 App Router app. No deliberation needed.
Why Clerk
I had a local-only admin panel from before the migration. Cookie-based auth, password stored in an env var, the whole thing blocked from production with if (process.env.NODE_ENV === 'production') redirect('/'). Worked fine when I was the only one using it on my laptop. Didn't work at all when I wanted to moderate a comment from my phone.
Clerk replaces all of that. One OWNER_CLERK_USER_ID env var, checked against the signed-in user in the admin layout. If you're not that user, you see an access denied screen. If you're not signed in at all, Clerk's middleware bounces you to /sign-in. No passwords stored anywhere, no session tokens to leak, no cookie logic to get wrong.
The admin panel now works in production. I can edit posts, pin comments, delete guestbook entries, all from my phone on a bad WiFi connection. That's the whole point.
The Database Setup
Neon is serverless Postgres. You get a connection string, you point Drizzle at it, and you push your schema. The schema is four tables:
blog_posts— one row per slug, just so comments have something to referenceblog_comments— the actual comments, with aparent_idfor threading andis_deletedfor soft deletesblog_comment_likes— composite primary key on(comment_id, clerk_user_id), so you can't double-likeguestbook_entries+guestbook_likes— same pattern
One thing I ran into: Neon's client errors at import time if DATABASE_URL isn't set, which meant the build was failing in CI because the Docker image doesn't have database credentials at build time. The fix was a lazy proxy wrapper around the Drizzle client — it only tries to connect when the first query runs, not when the module imports.
export const db = new Proxy({} as ReturnType<typeof getDb>, {
get(_target, prop) {
return getDbClient()[prop as keyof ReturnType<typeof getDb>]
},
})Ugly, but it works. Build passes without DATABASE_URL, queries work fine at runtime.
Comments
The comments section lives at the bottom of every blog post. Server component fetches the initial data (no loading spinner on first paint), client component handles all the interaction from there — posting, liking, replying, deleting.
Optimistic updates throughout. You click "Comment," your message appears instantly with your avatar, and if the server rejects it (rate limit, profanity filter, whatever), it disappears and you get the error. If it succeeds, the temporary negative ID gets swapped for the real one. No full-page reload, no stale data.
Threading goes two levels deep. Any comment can have replies, any reply can have replies, but you can't reply to a reply's reply. That's probably deep enough for a personal blog.
The admin panel at /admin/comments shows everything grouped by post, with search, a "pinned only" filter, and the ability to pin or delete any comment. Pinned comments float to the top of their post's section with a small accent indicator.
Guestbook
/guestbook is a simpler version of the same thing. 280-character limit, no threading. Sign in, leave a note. The distinction is intentional — comments are for reacting to specific posts, the guestbook is just for saying hi.
Security, Which I Underestimated
This is the part I didn't think much about until after I had comments working locally and started thinking about what happens when a real person finds the page.
The obvious stuff was already there: Clerk means no anonymous submissions, all input is plain text stored and rendered as-is through React (so no XSS), length validation server-side. That covers the basics.
What I didn't initially have: rate limiting and content filtering.
Rate limiting was easier than I expected. I don't have Redis. I don't need Redis. The rate limit check is just a DB query — count how many comments this clerk_user_id has submitted in the last 10 minutes, reject if it's over 5. One round trip to Neon, no extra infrastructure. For a personal blog this is completely fine.
Content filtering was more interesting to think through. The naive approach is a wordlist, which is what leo-profanity is. It catches obvious stuff and has leetspeak detection (so sh1t doesn't slide through), which is better than nothing. But wordlists don't catch context. "You're so stupid" passes every wordlist filter. Repetitive spam like "BUY NOW BUY NOW BUY NOW" passes too.
The production approach for semantic moderation is an ML API. Google's Perspective API is the obvious choice — free tier, no credit card, built specifically for comment moderation, used by the New York Times and a bunch of other publications. It scores text across dimensions like toxicity, spam, identity attacks, and threats. You set a threshold (0.8 is reasonable), reject anything above it, and let everything else through.
The fail-open pattern matters here: if Perspective returns a 429 because you hit their 1 QPS limit, you let the comment through rather than blocking it. A legitimate comment slipping past is recoverable (admin panel, delete it). A legitimate user being blocked by a rate limit is bad UX and hard to fix after the fact.
I haven't wired Perspective in yet — it needs a Google Cloud project and an API key, and I wanted to ship what I had first. It's on the list.
What I did add besides leo-profanity: String.normalize('NFKC') in the sanitizer. Unicode normalization sounds obscure but it matters. Without it, someone can write "shit" (fullwidth characters) or use zero-width joiners between letters and bypass any string-based filter. NFKC collapses all of that into the standard ASCII equivalent before the text ever reaches the filter.
The Deploy Broke (Obviously)
The first production deploy after adding Clerk failed. The build passed fine locally because I had all the env vars in .env.local. The Docker build in CI didn't, because NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY is a build-time variable — Next.js bakes NEXT_PUBLIC_* values into the static bundle at build time, so it needs to be available during next build, not just at runtime.
The Dockerfile already had a pattern for this from NEXT_PUBLIC_OPENWEATHER_KEY:
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYAnd the GitHub Actions workflow needed the corresponding build-args entry. Once that was wired up and the secret added to the repo, it built fine.
There was also a deploy workflow issue I caught after the fact: the workflow overwrites the .env file on the VPS every deploy, writing only the secrets that exist in GitHub Actions. I'd manually added the Clerk and Neon vars to the VPS .env, which meant the next deploy would silently delete them. The fix was adding all five new vars to GitHub secrets and updating the workflow to write them. Easy fix, would've been a confusing outage if I hadn't noticed.
What's Actually Left Now
- Perspective API for semantic moderation
- Clerk webhook registered on the live domain (needed for user deletion cleanup to work in prod)
- The other stuff from the original list that isn't auth-related
Tuff
as always