A File-Drop MDX Blog in Next.js 16 (Turbopack Edition)
This blog has a one-step publishing workflow: drop an .mdx file into content/blog/, and the post exists — listed on the index, prerendered at its own URL, present in the sitemap and RSS feed, with a branded Open Graph image generated from its title. No CMS, no database, no registry file to edit.
Here's the entire setup, including the two Turbopack-specific details that aren't obvious from the docs.
The pieces
1. MDX via @next/mdx. Posts are markdown with superpowers, rendered as Server Components:
// next.config.ts
import createMDX from "@next/mdx";
const nextConfig = {
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
};
const withMDX = createMDX({
options: {
// Turbopack requires plugin names as strings —
// JS functions can't cross into Rust.
remarkPlugins: ["remark-frontmatter"],
},
});
export default withMDX(nextConfig);
That string-based plugin syntax is Turbopack detail #1: you can't pass imported plugin functions like in the webpack days. remark-frontmatter matters because without it, the YAML block at the top of each post renders as visible text.
2. Frontmatter as the source of truth. Each post carries its own metadata:
---
title: "A File-Drop MDX Blog in Next.js 16"
description: "One file in a folder = one published post."
date: "2026-07-11"
tags: ["Next.js", "MDX"]
---
3. A filesystem registry. One function reads the folder, parses frontmatter with gray-matter, and derives everything else — including reading time from word count:
// lib/posts.ts
export function getPosts(): Post[] {
return fs.readdirSync(POSTS_DIR)
.filter((f) => f.endsWith(".mdx"))
.map((file) => {
const slug = file.replace(/\.mdx$/, "");
const { data, content } = matter(fs.readFileSync(path.join(POSTS_DIR, file), "utf8"));
const words = content.trim().split(/\s+/).length;
return { slug, ...data, readingMinutes: Math.max(1, Math.round(words / 200)) };
})
.sort((a, b) => (a.date < b.date ? 1 : -1));
}
One gotcha inside gotcha: YAML parses unquoted dates like date: 2026-07-11 into JavaScript Date objects, while quoted ones stay strings. Normalize both, or your sort will silently compare a Date against a string.
4. A dynamic route that imports MDX on demand. This is the pattern from the Next.js docs, and it's the glue:
// app/blog/[slug]/page.tsx
export const dynamicParams = false;
export function generateStaticParams() {
return getPosts().map((post) => ({ slug: post.slug }));
}
export default async function PostPage({ params }) {
const { slug } = await params;
const { default: Content } = await import(`@/content/blog/${slug}.mdx`);
return <article className="post-prose"><Content /></article>;
}
With dynamicParams = false, every post is prerendered at build time and unknown slugs 404. The template-literal import() keeps Turbopack able to statically trace which files might load.
5. Everything else derives from the same registry. The sitemap maps over getPosts(). The RSS route handler maps over getPosts(). The listing page maps over getPosts(). And each post's Open Graph image is generated by an opengraph-image.tsx in the [slug] folder that looks up the post title and renders it with ImageResponse — same fonts, same brand, zero design tools.
Why not a CMS?
For a personal blog, a CMS is a second system to keep alive — auth, webhooks, an editor UI, a vendor. A folder of files gets you: version control as edit history, pull-request review for posts, grep as search, and the ability to write in any editor. The publishing pipeline above is ~120 lines total, and there's nothing else to operate.
The complete blog — including this post as a file in that folder — ships with the portfolio it lives in.