Building this portfolio with Next.js 16
Everything that powers this site: MDX, view transitions, an in-browser AI narrator in a Web Worker, link previews, WebMCP, and a Dockerized Turborepo.
I wanted a place to share what I learn and discover as a frontend developer, without maintaining a heavyweight CMS. This site is the result: a few MDX files, a couple of pages, and some carefully chosen platform features. This post is the full tour — everything that powers the site and what I learned building each part.
The stack
- Next.js 16 with the App Router and Turbopack
- React 19 with the experimental
<ViewTransition>component - MDX for posts, compiled at build time
- Tailwind CSS v4 with a shadcn-style design system on Base UI primitives
- Transformers.js for an in-browser AI narrator (more on that below)
- Turborepo + pnpm so the UI package can be shared with future apps
Posts are just files
Every post is an .mdx file with frontmatter. A small server-only library
reads the directory and parses metadata with gray-matter:
export function getAllPosts(): PostMeta[] {
return fs
.readdirSync(POSTS_DIR)
.filter((file) => file.endsWith(".mdx"))
.map((file) => readPostMeta(file.replace(/\.mdx$/, "")))
.sort((a, b) => (a.date < b.date ? 1 : -1))
}The post page then imports the compiled MDX module dynamically, so the bundler does all the heavy lifting and the page stays fully static:
const { default: Content } = await import(`@/content/posts/${slug}.mdx`)One Turbopack gotcha: MDX plugins in next.config.ts must be passed as
string identifiers ("remark-gfm"), not imported functions, because the
config has to be serializable. Syntax highlighting comes from
rehype-pretty-code with dual Shiki themes that follow light and dark mode.
Every post is also Markdown
Each post has a sibling route, /blog/[slug]/markdown, that serves the raw
source as text/markdown. The "···" menu on every post builds on it: copy
the page as Markdown, view it as plain text, or open it in ChatGPT or Claude
with a pre-filled prompt. If an LLM is going to read my posts anyway, it
might as well get clean Markdown instead of scraping HTML.
An AI narrator that runs in your browser
The audio player on each post is not a recording and not a paid TTS API — it's the MMS text-to-speech model running entirely in your browser via Transformers.js. The ~40MB quantized model downloads once and is cached.
Two hard-won lessons made it feel right:
Inference must live in a Web Worker. My first version ran the model on the main thread, and the whole page froze while it generated audio. WASM inference is CPU-heavy; the worker keeps the page at 60fps and posts the audio back as a transferable buffer, so no copying either.
Schedule, don't react. Playing chunks back-to-back with onended
callbacks produced audible gaps. Instead, every generated chunk is scheduled
at an exact timestamp on the AudioContext timeline, gaplessly extending
the chain while the worker keeps generating. Playback starts once roughly
20% of the narration is buffered, and if it ever catches up with generation
it stalls at the buffered edge and resumes automatically.
// Chain still running: append gaplessly at its scheduled end.
startSource(buffer, chainEndRef.current, 0, scheduleTokenRef.current)
chainEndRef.current += buffer.duration / rateRef.currentThe player itself handles seeking, 1–2× playback speed, and downloading the finished narration as a WAV file encoded right in the browser.
Small details that compound
- View transitions. Click a post in the list and its title morphs into
the article heading — React's
<ViewTransition>with a matchingnameon both pages, plus aprefers-reduced-motionescape hatch. - Link previews. External links like the one to AITC International show a live screenshot of the target site in a tooltip on hover.
- Command menu. Press
⌘Kfor client-side search over aposts.jsonindex generated at build time, andgthen a letter to jump between pages. - WebMCP. The site exposes its content as tools that AI agents can call directly when browsing — search posts, read pages — instead of scraping.
- PWA. A service worker precaches pages for offline reading, with RSS, sitemap, robots, and per-post Open Graph images generated by route handlers.
Guard rails and shipping
Commits run through lefthook: turbo lint and turbo typecheck on
every commit, and commitlint enforcing conventional commit messages.
Hooks install automatically via the root prepare script.
Deployment is a multi-stage Dockerfile. turbo prune web --docker
strips the monorepo down to what the app needs, and Next's standalone
output keeps the final image to a Node runtime plus a self-contained server
bundle — docker compose up is the whole deploy story.
RUN turbo prune web --docker
# ...
COPY --from=builder /app/apps/web/.next/standalone ./
CMD ["node", "apps/web/server.js"]What I'd tell past me
Start minimal. It is much easier to add a feature to a small site than to delete one from a big template you don't fully understand. Every feature above earned its place one at a time — and the weird ones, like running a speech model in a Web Worker, only worked out because the rest of the site stayed simple.
Enjoyed this post?
Subscribe to get future posts straight to your inbox.