Next.js 16 App Router gotchas we hit

Async headers(), the proxy.ts rename, dynamic html lang, and four other small things that bite when you cross the 15→16 line.

We moved this site to Next.js 16 the week the stable release dropped. Most of the upgrade was clean — the App Router shape stayed the same, RSC behavior was unchanged, and the deploy story on Dokploy worked on the first try. But seven small details in the changelog are easy to miss and produce confusing failures. Here is the list, in the order we hit them.

1. headers() is async now

The headers() helper from next/headers used to be synchronous. In Next.js 16 it returns a Promise. Anything calling it must be an async function and await the call. The compiler will not always catch this — the function still type-checks if you forget the await, and you get a Headers object with weird, lazy behavior at runtime.

If you read a request header in a layout to derive html lang (we do — see proxy.ts setting x-locale), the layout itself becomes async. Server Components handle that fine, but it changes the dynamic-vs-static boundary. Both / and /es are now marked dynamic (ƒ) in the route table because the root layout awaits headers() to set <html lang>.

2. The middleware file is renamed to proxy

The convention deprecated middleware.ts and renamed it to proxy.ts at the project root. The file exports the same function (default export or a named proxy(req: NextRequest)). The matcher config also stays the same. But you cannot have both files — Next.js picks proxy.ts and ignores middleware.ts silently.

If you are upgrading a project that has middleware.ts, rename it. Do not symlink. Do not add a re-export shim. The file system convention is the API.

3. Dynamic html lang requires the layout to be async

If you derive <html lang> from request state, the root layout has to be async, which makes the entire route subtree dynamic. That is the right answer for a bilingual site — there is no way to statically render the same component tree as either lang=en or lang=es from the same compile.

If you tried to keep static rendering by setting lang on the body or by branching on usePathname() in a client component, you would lose the SSR signal that crawlers and screen readers use. We accept the dynamic flag.

4. Per-route metadata still wins over layout metadata

Generate alternates per page using a helper that knows the route key. We use alternatesFor(key) for the EN side and alternatesForLocale(locale, key) for the ES side. The helpers compose canonical and the languages map from a single source of truth in i18n/routes.ts.

Do not hand-roll the alternates object on each page. We did that for the first few landings and ended up with hreflang drift between EN and ES on three of them. The helper makes drift impossible.

5. Standalone build does not ship app/ source

next.config.ts has output: 'standalone' so the Docker image only includes the built server and the minimum node_modules. That means anything that does fs.readFileSync('app/...') at runtime will fail in production with an ENOENT.

We hit this on the sitemap, which used to derive lastmod from git log per app/page.tsx. The file is not in the standalone container, and git is not in the container either. The fix is a build-time JSON snapshot — a prebuild script writes lib/sitemap-lastmod.json, the sitemap reads from JSON at runtime. Both git and the source files only need to exist at build time.

6. CSP enforce mode + Tailwind v4 + next/font

We enforce CSP in production. Tailwind v4 ships inline styles in some scenarios and next/font injects @font-face rules into the head. Both need style-src 'unsafe-inline' or a per-request nonce. We allow 'unsafe-inline' on style-src as a pragmatic trade-off and continue to lock down script-src.

Adopting nonce-based CSP for script-src on Next.js 16 is doable through proxy.ts but tightens the testing surface — RSC payloads, Tailwind, and any inline event handlers all need the nonce wired correctly. Worth doing, not worth doing on a Friday afternoon.

7. The deprecation log is the documentation

Most of the breaking changes above show up as deprecation warnings the first time you run next dev. Read them. The Next.js team is generous about pointing you at the migration path inline. The changelog under node_modules/next/dist/docs/ is also more reliable than blog posts on the web — we now grep that directory before searching.

None of these were show-stoppers and the upgrade took about a day end to end. But each one was a small surprise — exactly the kind of thing that costs a team an afternoon if they hit it during a release window. We wrote the list down so the next team that goes through it does not.