The hello world post was an intro with a stack list. This is the follow-up that actually explains things — why certain choices were made, where I ran into walls, and what building most of this with AI assistance actually looks like in practice.
Stack, with reasons this time
| Layer | Package | Version |
|---|---|---|
| Framework | next | 16.2.9 |
| Runtime | React | 19.2.4 |
| Styling | tailwindcss | v4 |
| Animation | motion | v12 |
| Content pipeline | velite | ^0.4.0 |
| UI primitives | @base-ui/react via shadcn | ^1.6.0 |
| PDF generation | @react-pdf/renderer | ^4.5.1 |
| Validation | zod | v4 |
| Themes | next-themes | ^0.4.6 |
| Diagrams | mermaid + panzoom | ^11 / ^9 |
A few of these need unpacking.
Velite over Contentlayer. Contentlayer powered most Next.js MDX setups for a while. It went unmaintained in 2024. Velite does the same job — compiles MDX at build time into typed TypeScript objects — with active maintenance and clean Turbopack support. The migration path is also straightforward if you're coming from Contentlayer.
@base-ui/react instead of Radix. The shadcn base-nova preset swaps the underlying primitive library from Radix to Base UI. The main API difference: there is no asChild prop. Anywhere you'd normally write <Button asChild><Link href="...">text</Link></Button>, you apply buttonVariants() directly to the <a> tag instead. Takes about ten minutes to get used to, but it trips you up every time before that.
Zod v4. The validators moved to top-level functions. It's z.email() and z.url() now, not z.string().email(). If you're copying schema code from older projects, this will silently fail type-checking until you notice.
motion v12. This is Framer Motion with a new package name. Import from "motion/react" instead of "framer-motion". The old import still resolves for a while but starts warning.
File structure
src/
├── app/
│ ├── layout.tsx ← true root: html, body, ThemeProvider, fonts
│ ├── [lang]/
│ │ ├── layout.tsx ← nested layout: FloatingNav, metadata (no html/body)
│ │ ├── page.tsx ← about / home
│ │ ├── not-found.tsx ← 404 rendered inside the layout (nav still shows)
│ │ ├── [...rest]/page.tsx ← catch-all → calls notFound()
│ │ ├── work/page.tsx
│ │ ├── blog/
│ │ │ ├── page.tsx
│ │ │ └── [slug]/page.tsx
│ │ └── skills/page.tsx
│ ├── api/
│ │ ├── resume/route.tsx
│ │ └── cover-letter/route.tsx
│ ├── opengraph-image.tsx
│ ├── sitemap.ts
│ └── robots.ts
├── components/
│ ├── nav/
│ │ ├── FloatingNav.tsx ← client component — usePathname + Motion
│ │ └── ThemeToggle.tsx
│ ├── sections/ ← WorkTimeline, SkillGrid, BlurFade wrappers
│ ├── ui/
│ │ ├── mermaid.tsx ← lazy-loaded Mermaid renderer with panzoom
│ │ └── collapsible-summary.tsx ← client: expand/collapse summary on mobile
│ ├── HtmlLang.tsx ← client component: updates html[lang] per locale
│ └── mdx-content.tsx ← MDX renderer + component overrides
├── content/blog/*.mdx
├── dictionaries/ ← en.json, zh.json, ja.json
├── i18n.ts
├── proxy.ts ← Next.js 16: this was middleware.ts
└── lib/
├── pdf-fonts.ts ← CJK font registration
└── locale-data.ts ← helpers that merge translated + TS dataOne thing worth calling out: there IS a src/app/layout.tsx — it's the true root and owns <html>, <body>, and ThemeProvider. The [lang] nested layout returns a plain fragment, handling only locale-specific chrome (FloatingNav) and metadata. Splitting them keeps ThemeProvider stable across locale switches, which prevents a dark-mode white flash when the locale button is clicked.
i18n without a library
No next-intl, no react-i18next. The whole locale system is built on the native App Router pattern: a [lang] dynamic segment, JSON dictionary files, and a proxy that redirects bare URLs to the default locale.
The proxy.ts thing deserves a specific callout. In Next.js 16, middleware.ts was deprecated. The file is now proxy.ts and the export is function proxy(request). Same functionality, renamed. It doesn't shout about this change, so if you miss it, your middleware silently does nothing.
Why does FloatingNav receive nav labels as a prop instead of fetching the dictionary itself? It's a client component — it uses usePathname() to highlight the active link, which requires browser APIs. Client components can't call async server functions like getDictionary(). The parent server layout calls it and passes down the translated strings. This also keeps all the dictionary JSON out of the client bundle.
English is the default locale. en.json only carries UI strings: nav labels, page headings, button text. The actual content — work history, bio, skills — lives in TypeScript data files and feeds directly into the English pages. zh.json and ja.json carry both UI strings and translated content, because you can't machine-translate a professional bio and ship it without reading it.
PDF generation with language support
The site generates resumes and cover letters as PDFs on the server. Request /api/resume?lang=zh and you get a Chinese-language resume with proper CJK typography.
@react-pdf/renderer uses a React Native-style layout engine. No Tailwind, no HTML — just View, Text, Page, and StyleSheet.create() with flexbox values. It takes a while to stop reaching for className out of muscle memory.
Two bugs here cost me real debugging time.
The Fragment bug. BulletList originally returned a React fragment (<>...</>). The Yoga layout engine inside react-pdf can't measure fragment children properly — it underestimates the height of entries, and content bleeds across page breaks. The fix was changing the fragment to a <View>. One word different. The symptom (content overflowing page breaks) looked like a dozen possible causes.
The flex: 1 bug. flex: 1 in a flexDirection: "row" context fills remaining horizontal space — exactly what you want for text wrapping. I reused the same style in a column context, where it made Yoga inflate the element to fill all remaining vertical space on the page. Same overflow symptom, completely different cause.
CJK fonts add another layer of complexity. @react-pdf/renderer can't use system fonts or web fonts, so Noto Sans SC, TC, and JP are shipped as WOFF files in public/fonts/ — about 1.6 MB per weight. On a Vercel cold start, that means WOFF decompression, glyph table parsing, and dynamic subsetting before the PDF renders. First request for a CJK PDF takes 1–3 seconds. After that, the CDN cache handles it.
A specific Motion gotcha
The floating nav has an entrance animation — slides down from y: -12 to y: 0. Simple enough. The problem: Motion injects a CSS transform to drive the animation, and Tailwind's -translate-x-1/2 (which centers the fixed nav) is also a transform. Put them on the same element and one wins, the other doesn't apply.
The fix is to separate the two onto different elements:
{
/* Outer div: only handles position + centering */
}
<div className="fixed top-4 left-1/2 z-50 -translate-x-1/2">
{/* Inner motion.nav: only handles the animation */}
<motion.nav initial={{ opacity: 0, y: -12 }} animate={{ opacity: 1, y: 0 }}>
...
</motion.nav>
</div>;The same pattern applies to the mobile nav (bottom-center, slides up), with separate layoutId values for the active-link pill so desktop and mobile animations don't interfere with each other.
Mermaid diagrams in MDX
Since this blog has complex diagrams, I added Mermaid rendering directly to the MDX pipeline. You write a fenced code block with the mermaid language tag, and it renders as an interactive SVG — scroll to zoom, drag to pan.
The tricky part: rehype-pretty-code (which handles syntax highlighting) would try to highlight Mermaid blocks as code. They run through the same pipeline. I added a custom rehype plugin that runs before the highlighter and lifts mermaid blocks out into a <div data-mermaid> element, which the MDX renderer maps to the client-side Mermaid component.
The Mermaid library itself is lazy-loaded — it's about 200 KB gzipped and only downloads when you visit a page that actually has a diagram.
The prompt-driven development workflow
Most of this site was built with Claude Code in sessions that sometimes ran for hours. The part nobody really writes about: keeping an AI assistant coherent across multiple sessions is its own engineering problem.
Here's what I landed on.
A living implementation document. There's a file at .prompt/docs/IMPLEMENTATION-SUMMARY.md that logs every significant architectural decision with context — why proxy.ts and not middleware.ts, why the Fragment became a View, what the Zod v4 gotchas are, what the known performance characteristics of CJK PDFs are. When a session runs out of context window, the next one starts by reading this. It's not magic, but it means I'm not re-explaining the same constraints from scratch every time.
Structured prompt files before implementation. Before building a feature, I write a short spec in .prompt/ — objective, current behaviour, requirements, acceptance criteria. For bugs, a FIX-*.md file with what's happening, what I expect, and what I've already ruled out. This looks like overhead, but it forces you to be precise about what you actually want, which matters more than any prompting trick.
The pattern that works: be explicit about constraints upfront. "Don't introduce new dependencies unless necessary", "follow the existing coding style", "don't change anything outside of these files" — these constraints need to be stated, not assumed. The AI will otherwise optimise for the solution in isolation, not for the solution that fits your existing system.
What works well: greenfield implementation from a clear spec, refactoring with well-defined before/after behaviour, translation work, writing first drafts of anything. What still needs a human: judging whether the UX actually feels good, making architectural calls the spec didn't cover, deciding when to push back on a direction that's technically correct but wrong for the project.
The summary: useful for a project like this. Would require more structure at production scale, where the cost of a wrong decision compounds.
More posts as there's more to say.