Next.js 16 is a good release, I want to say that before spending the rest of the article griping about it. The App Router has matured, Turbopack became the default, build times have melted away. On paper, all is well. And yet, redeploying this portfolio this week (Next 16, self-hosted, Docker image, no Vercel to cushion the corners), I walked into four walls the tutorials never show. What they have in common is that none of them throws an error. The build goes green. That, precisely, is the problem.
The middleware is called proxy.ts now
First thing that throws you when you land on Next 16: the middleware file isn't called middleware.ts anymore, it's called proxy.ts. It's a convention rename, not an option. If you show up with your Next 14 or 15 reflexes, you go looking for a file that no longer exists.
The trap isn't in the code, it's in the tools that aren't aware of it yet. An automated audit of my own repo flagged it to me as a bug: "non-standard proxy.ts file, rename to middleware.ts". A complete false positive. Had I listened to it, I'd have broken the site's whole i18n routing. The proof that the convention is the right one is live: a request to / answers with a 307 to /fr. That's exactly the job of the next-intl middleware, and it runs, in a file named proxy.ts.
// src/proxy.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: ['/', '/(fr|en)/:path*', '/((?!api|_next|_vercel|docs|.*\\..*).*)'],
};
The lesson isn't "proxy.ts is weird". It's more like: when a linter or an audit flags a framework convention as an anomaly, go check your version's docs before you "fix" it. On a rename this recent, the tool is often a step behind the framework.
generateStaticParams doesn't get the locale from its parent
The second trap cost me the most brainpower, and it's genuinely sneaky. My article route lives in [locale]/blog/[slug]. Two nested dynamic segments: the language first, the slug next. To generate the pages at build time, the child's generateStaticParams needs to know the locale of the parent segment.
Except that in Next 16, that parent params.locale doesn't propagate reliably into the child's generateStaticParams. Concretely, I was calling my listing function with an undefined locale. And getAllPosts(undefined) doesn't crash, it politely returns an empty array. Zero slugs generated. Since I'm set on pure static, I also have dynamicParams = false, which means "any URL I haven't pre-generated doesn't exist". You can see where this is going: zero pre-generated pages plus zero on-the-fly rendering makes an entire blog return 404.
The fix is to stop waiting on the parent locale and enumerate it myself. I loop over the locales declared in my routing and return the complete { locale, slug } pairs, spelled out in full.
// src/app/[locale]/blog/[slug]/page.tsx
import { routing } from '@/i18n/routing';
import { getAllPosts } from '@/lib/blog';
export const dynamicParams = false;
export async function generateStaticParams() {
const allParams: { locale: string; slug: string }[] = [];
for (const locale of routing.locales) {
const posts = await getAllPosts(locale);
for (const post of posts) {
allParams.push({ locale, slug: post.slug });
}
}
return allParams;
}
Nothing magic once you've understood it. The treacherous detail is that the "naive" code (the one that trusts the parent's params.locale) compiles without a hitch. It only breaks at render time, and only for real in production.
A green build proves nothing
Here's the real subject of the article, the one that wraps the two before it. When generateStaticParams returns an empty array, Next doesn't treat that as an error. It concludes there's nothing to pre-render, and with dynamicParams = false, it prerenders the page as a 404. No warning. No exit code of 1. The pipeline stays all green. Same story for any page that calls notFound() in a case you hadn't anticipated: it gets frozen as a 404 at build, silently.
I lived it this week, and it's maddening. A blog article deployed, live, a clean 404, while next build had passed all its checks and the image had already shipped to production. No signal anywhere. You discover the broken page like any random visitor, by clicking on your own link.
It's counterintuitive because we've all learned to trust the green light. The build compiles, so "it works". No. The build tells you your code is syntactically valid and your types hold. It tells you nothing about what your pages actually return when a human asks for them. A prerendered 404, to the compiler, is a perfectly valid page. It just has the wrong content.
The fix: test the server that actually runs
The only thing that catches these silent 404s is to launch the real server and watch what it answers. And there, a second surprise specific to self-hosting: with output: "standalone", the next start command doesn't serve your app. It's not a bug, it's just no longer the right command. You have to start the standalone server by hand, copying .next/static and public alongside the way the Docker image does.
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
node .next/standalone/server.js
# then, in another terminal, check the key pages
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/fr/blog/my-article
Thirty seconds, and the 404 jumps out at you before it ships to production. I turned it into a small smoke test I run before every deploy, which boots the standalone server and checks with curl that my important URLs do answer 200. It's not glamorous. It's just the only way I know to tell "the code compiles" apart from "the site works". For the full mechanics of a self-hosted deploy (standalone image, registry, reverse proxy), I went into detail in my article on hosting a Next.js app on a VPS; here I mainly wanted to point at what a green build hides from you.
Two last stones in the shoe
While we're on Turbopack, it comes with its own subtlety when you write in MDX. Under Turbopack (the default in 16), remark and rehype plugins only receive serialisable options. You can pass them strings, objects, arrays, but not a function. If your plugin config worked with a callback back in the Webpack days, it'll let you down with no clear explanation. You have to rethink the config as pure declarative.
And a last one, which isn't specific to Next but bites in exactly the same spot: changing a dependency without regenerating yarn.lock. Locally, yarn install patches the gap on its own and you see nothing. In CI and in the Docker build, I install with --immutable, which flatly refuses to touch the lockfile. The slightest mismatch between package.json and yarn.lock fails the install, so the build, so the deploy. Once again, it passes locally and breaks where you're not watching live.
The thread running through this week is just that: on a managed platform, a per-branch preview might have shoved the 404 under your nose before the merge. Self-hosted, no one looks in your place. The only thing I still grant my trust to isn't GitHub's green badge, it's the standalone server answering 200 on my machine before the image takes off. A build that compiles talks to you about your code. It will never say a word about what your visitors are going to see.
If you deploy self-hosted Next.js and you want an outside eye before it ships to production, let's talk.
Top comments (0)