Shipping a portfolio on Next.js 16 and MDX
2 min read
- Next.js
- TypeScript
- Tooling
This site had been a single page for a while. Adding a blog meant answering one
question first: where does the content live? A CMS was out — there is no login,
no database, and I wanted the posts in the same review flow as the code. So the
posts are MDX files in content/blog/, and the whole section is one dynamic
route that gets compiled at build time.
A content layer, not a routing convention
The tempting shortcut is to drop each .mdx file into app/blog/<name>/page.mdx
and let file-based routing do the rest. It works, and it is the wrong shape here.
Filenames would become URLs, which means renaming a file silently breaks a link,
and translations, drafts and prev/next all end up scattered across the tree.
Instead, every post declares its own identity in frontmatter:
slug: shipping-a-nextjs-portfolio
lang: en
translationKey: nextjs-portfolio
title: Shipping a portfolio on Next.js 16 and MDX
publishedAt: 2025-11-18
image: /images/project-fastapi.png
tags:
- nextjs
- typescript
draft: falseA lib/blog/posts.ts module reads those files with gray-matter, validates
them, sorts them newest first and hands back plain objects. The route itself is
boring, which is the point:
export default async function Page({ params }: PageProps<'/[locale]/blog/[slug]'>) {
const { locale, slug } = await params
const post = getPost(locale, slug)
if (!post) notFound()
const { default: Body } = await import(`@/content/blog/${post.file}`)
return <BlogPost post={post} body={<Body />} />
}Note
generateStaticParams lists every locale/slug pair, so the import above is
resolved at build time and Turbopack can bundle each post as its own chunk.
Reading time belongs in the data
The grid card and the post page show the same number, so the number is computed once, in the content layer, and stored on the post object. Anything else drifts the moment you edit either side.
const WORDS_PER_MINUTE = 200
function readingTime(body: string): number {
const words = body.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean)
return Math.max(1, Math.round(words.length / WORDS_PER_MINUTE))
}Code counts. Prose-only word counts flatter an article that is half snippets.
Drafts that only exist locally
A draft: true post is invisible in production: no page, no card, no prev/next
slot. In next dev it is generated like any other post, which is all a preview
environment needs to be useful.
const includeDrafts = process.env.NODE_ENV !== 'production'Two builds, two audiences
The draft filter has to live in the content layer, not the page. Filtering later still emits the route, and a stale link then 404s in production instead of simply not existing.
Syntax highlighting without a runtime
Shiki runs during the build. Both themes are emitted at once as CSS variables, so the light/dark toggle is a CSS change and the client ships no highlighter at all.

What I would do differently
Two things. First, I would have written the frontmatter validator before the
first post, not after the third. Second, I would have picked the translation key
naming scheme earlier; translationKey reads better than the translationOf
slug pointer I started with, because it does not force one language to be the
canonical version.