---
title: Static exports do not forgive
description: Four ways a Next.js static export broke during a rebuild, and what each one was actually telling me.
date: 2026-09-21
area: development
tags:
  - nextjs
  - build
draft: false
---

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:

```js
// 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:

```ts
const mod = await import(`@/content/blog/${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:

```ts
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.
