Static exports do not forgive
A static export is the most honest deployment target there is. There is no server to paper over a mistake at runtime, so everything that can go wrong goes wrong at build time or not at all. That sounds restful. In practice it means the build failures you get are strange, and the ones you do not get are worse.
Four from rebuilding this site.
Turbopack cannot take your functions
Turbopack is the default builder for next build in Next 16, and it runs in
Rust. Which means the remark and rehype plugins you hand to MDX have to cross
a language boundary:
// Works.
rehypePlugins: [['rehype-pretty-code', { theme: 'github-dark-dimmed' }]]
// Does not. There is no way to send this to Rust.
rehypePlugins: [[rehypePrettyCode, { transformers: [myTransformer()] }]]String names with serializable options, or nothing. Every shiki tutorial you
will find reaches for transformers or getHighlighter in about paragraph
three. Those are functions. They will break your build, and the error will not
say "this is a function."
An empty directory is not an empty list
This one is my favourite, because the code was already correct.
Content loads through a dynamic import:
const mod = await import(`@/content/writing/${slug}.mdx`);The routes already handled an empty collection — no posts means a sentinel slug and an empty state, all tested. Then I deleted the last file out of a content directory and the build died:
Module not found: ./src/content/plugins/ <dynamic> .mdxA dynamic import with a template literal is not a lookup. The bundler resolves it to a context module at build time: every file that could possibly match, compiled in advance. A directory with zero matches has nothing to build a context from, so the module fails to resolve — long before any of my careful empty-state handling gets a chance to run.
The fix is dull. Each content directory keeps a _empty.mdx that the loader
skips. The interesting part is the category: I had handled the empty case in
the layer I was thinking about, and not in the layer underneath it.
Layered CSS loses, and does not tell you
Tailwind's typography plugin registers its rules inside a Tailwind layer. My
overrides were in @layer base. Specificity said I should win. The cascade
disagreed, because unlayered CSS beats layered CSS regardless of
specificity, and between two layers the later one wins.
The result was code blocks rendering dark-on-dark in light mode, and literal
backticks around every piece of inline code — the plugin's ::before content,
which my content: none was quietly losing to.
No error. No warning. It looked like a markdown parsing bug, which sent me looking in exactly the wrong place. I found it by opening the page.
Route handlers do work, actually
The received wisdom is that output: 'export' rules out route handlers. It
does not, quite. A GET handler marked static builds to a real file:
export const dynamic = 'force-static';
export function GET(): Response {
return new Response(rss, {
headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
});
}I had planned a whole postbuild script to flatten out/rss.xml/index.xml back
into out/rss.xml, on the assumption that trailingSlash: true would mangle
it. Then I ran the build and looked:
$ ls -la out/rss.xml
-rw-r--r-- 1 924 out/rss.xmlA file. The script was never needed. Ten minutes of checking saved an afternoon of building the wrong thing, which is the only reliably profitable trade in this work.
The pattern
Three of these four were silent. The build passed, or the failure pointed somewhere unrelated. The only reason any of them got caught was a habit that has nothing to do with Next.js: after the tests go green, open the thing and look at it.