Static exports do not forgive

3 min#nextjs#build

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

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

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