---
title: "I Rewrote My Entire Portfolio in Next.js (And Almost Lost My Mind Over One Date)"
description: "Migrating xevrion.dev from React + Vite to Next.js: Fumadocs, Bun, dynamic OG images, and the Docker bug that took hours to find."
date: 2026-06-11
tags:
  - nextjs
  - migration
  - web
  - docker
---

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.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 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,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. You can preview any combination locally:

```
http://localhost:3000/og?title=My+Post&tags=nextjs,typescript&path=blogs
```

This 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.**

```dockerfile
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:

```dockerfile
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, 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.

```diff
 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.

```dockerfile
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/*.md` originals
- ~~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*.mdx` route 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 reference
- `blog_comments` — the actual comments, with a `parent_id` for threading and `is_deleted` for soft deletes
- `blog_comment_likes` — composite primary key on `(comment_id, clerk_user_id)`, so you can't double-like
- `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 around the Drizzle client — it only tries to connect when the first query runs, not when the module imports.

```ts
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 "ｓｈｉｔ" (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`:

```dockerfile
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 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
