All posts

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: false

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

Screenshot of the Area project dashboard
Project imagery is reused across the blog cards — no per-post hero images.

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.

The source for this siteComponents, content layer and MDX pipeline, all in one repo.github.com