I Rewrote My Entire Portfolio in Next.js (And Almost Lost My Mind Over One Date)

Migrating xevrion.dev from React + Vite to Next.js: Fumadocs, Bun, dynamic OG images, Clerk auth, comments, and the Docker bugs that took hours to find.

June 11, 2026·updated June 21, 2026·10 min read··view raw

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 migration, the features I added after, and the bugs that were entirely my fault.


The Big SwapsCopied!

  • 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.html all deleted
  • useNavigate/useLocation swapped for useRouter/usePathname
  • /posts renamed to /blogs everywhere
  • @import url(googleapis.com) fonts swapped for next/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 HTMLCopied!

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 initial publishing. Sounds simple. It was not simple — more on that when we get to Docker.


Converting 18 Posts by HandCopied!

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 ImagesCopied!

src/app/og/route.tsx. Edge runtime, next/og's ImageResponse, zero extra dependencies. Generates a 1200×630 OG image on the fly using the site's actual palette: taupe background, diagonal stripe texture, soft-royal-blue accents.

?title=...&description=...&path=...&tags=comma,separated

Every 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. There was no OG image system before — this was a happy addition, not a replacement.


The Single-Page LayoutCopied!

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.

/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.


The API Routes MoveCopied!

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 entirely dead though. It's still running 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 HistoryCopied!

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 postinstall

2. .git was in .dockerignore.

Even after fixing the build order, the dates still didn't show. Because .dockerignore had .git in it — 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
 .github

3. 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."


Comments and GuestbookCopied!

With the migration settled, I went back to the "what's still left" list. The guestbook and comments line had been sitting there guilt-tripping me. So I built them.

The stack: Clerk for auth, Neon (serverless Postgres) for the database, Drizzle as the ORM. Schema is straightforward — four tables:

  • blog_posts — one row per slug, just so comments have something to reference
  • blog_comments — threaded comments with parent_id and soft deletes via is_deleted
  • blog_comment_likes — composite primary key on (comment_id, clerk_user_id), no double-liking
  • guestbook_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 — it only connects 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.

The comments section lives at the bottom of every blog post. Server component fetches the initial data, client component handles all the interaction from there — posting, liking, replying, deleting. Optimistic updates throughout: your message appears instantly with your avatar, disappears with an error message if the server rejects it, and gets its real ID swapped in if it succeeds. No full-page reload.

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. Probably deep enough for a personal blog.

/guestbook is the simpler sibling — 280-character limit, no threading, just sign in and leave a note. The distinction is intentional: comments are for reacting to specific posts, the guestbook is for saying hi.

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 with a small accent indicator.


Auth: Why ClerkCopied!

I had a local-only admin panel before this. Cookie-based auth, password stored in an env var, 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 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, 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 from any device. That's the whole point.


Security, Which I UnderestimatedCopied!

This is the part I didn't think much about until I had comments working locally and started thinking about what happens when a real person finds the page.

The obvious stuff was already covered: Clerk means no anonymous submissions, input is plain text rendered through React (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 expected. I don't have 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.

Content filtering is more interesting. The naive approach is a wordlist, which is what leo-profanity is. It catches obvious stuff and has leetspeak detection, which is better than nothing. But wordlists don't catch context — "you're so stupid" passes every wordlist filter.

The production approach for semantic moderation is an ML API. Google's Perspective API is the obvious choice — free tier, built specifically for comment moderation, used by the New York Times. It scores text across dimensions like toxicity, spam, and identity attacks. You set a threshold, reject anything above it. The fail-open pattern matters here: if Perspective returns a 429, let the comment through rather than blocking it. A legitimate comment slipping past is recoverable. A legitimate user getting blocked by a rate limit is bad UX.

I haven't wired Perspective in yet — it's on the list.

What I did add besides leo-profanity: String.normalize('NFKC') in the sanitizer. Without it, someone can write "shit" in fullwidth characters or use zero-width joiners between letters and bypass any string-based filter. NFKC collapses all of that into standard ASCII before the text reaches the filter.


Deploying Clerk (Another Round of Silent Failures)Copied!

The first production deploy after adding Clerk failed. The build passed 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.

ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY

And 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 subtle deploy workflow issue: 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 directly to the VPS, which meant the next deploy would silently delete them. Fixed by adding all five new vars to GitHub secrets and updating the workflow. Easy fix, would've been a confusing outage if I hadn't noticed.


Switching to a Production Clerk InstanceCopied!

After everything was working locally, there was one more thing to deal with: the "Development mode" banner Clerk puts on every page when you're using dev API keys. It's a red bar at the bottom of the screen. Looks terrible.

Fixing it means creating a production Clerk instance — completely separate from dev. They don't share users, keys, or anything. This is intentional.

The production instance needs a real domain. Clerk requires five DNS records: three for email deliverability (SPF/DKIM), one for the Frontend API, one for the Account Portal. All as CNAME records. I manage DNS through Cloudflare, added all five via the Cloudflare API, Clerk verified them and issued SSL certificates. Took about three minutes.

Then OAuth apps: the dev instance uses Clerk's own OAuth credentials, which means Google and GitHub sign-in screens say "Sign in to Clerk" instead of "Sign in to xevrion.dev." For production you register your own. Created a Google Cloud project, a GitHub OAuth app, a Discord app, pointed all redirect URIs at https://clerk.xevrion.dev/v1/oauth_callback, copied the client IDs and secrets into Clerk.

Then swapped the API keys in GitHub secrets (pk_live_... instead of pk_test_...) and pushed. The "Development mode" banner was still there. Turns out the empty commit I pushed to trigger a redeploy didn't touch any paths the build workflow watches, so the build never ran — the deploy just pulled the same old Docker image with the dev key baked in. NEXT_PUBLIC_* variables get baked into the bundle at build time. Updating a GitHub secret doesn't retroactively change a cached image. Touched the Dockerfile with an empty line, pushed, build ran, banner gone.

One more thing broke after switching instances: the admin panel showed "Access Denied." The OWNER_CLERK_USER_ID I'd set was my user ID from the development instance. The production instance assigned me a completely different user ID when I signed in for the first time. Updated the secret, triggered another deploy, back in.

This is one of those things that's obvious in retrospect but catches you off guard the first time. Dev and prod are genuinely separate — your user IDs don't carry over, anything hardcoded to a specific user ID needs to be updated.


Was It Worth ItCopied!

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. Comments and a guestbook that actually work in production. An admin panel I can reach from my phone.

But also: I migrated a production site's entire framework, package manager, and content pipeline simultaneously, in place, on a Friday — then kept building features on top of it. Every deploy broke in some new way. None of the breakages were catastrophic. All of them were fixable.

The common thread across all of it — the git history bug, the Neon import crash, the baked-in env vars, the dev-vs-prod user IDs — is that the failures were silent. Everything "succeeded." The site looked fine. It just quietly did less than it should, and nothing told you. That's the failure mode worth watching for: not the crash, but the missing feature that nobody notices is missing.

It's live. The "Updated" dates are accurate. The comments work. And I have a cleanup list that keeps getting longer.

Comments