next-intl static export locale 404
output: 'export' is added, the build succeeds, and the deployed site 404s on every localized dynamic route. /de works. /de/produkte works. /de/produkte/42 does not, and neither does anything else that used to be resolved at request time.
Nothing is misconfigured in the i18n layer. A static export removes the runtime that localized routing was relying on.
Root cause: there is no server to ask
A server-rendered Next.js application resolves a request at request time. Middleware inspects headers, chooses a locale, redirects; a dynamic route renders whatever parameter it is given. Localized routing is built on both of those.
A static export removes them. The build emits a tree of HTML files, and the host serves the file matching the path or answers 404. Middleware never runs, because there is nothing to run it. Request headers are unavailable, because there is no request-time code. And a dynamic route exists only for the parameter values that were enumerated at build time.
That last point is the direct cause of the 404. A dynamic segment under a locale segment has two variables, and generating one without the other produces a tree that covers a fraction of the real routes — usually the default locale’s, because that is what the enumeration was written against.
Minimal reproducible example
// app/[locale]/produkte/[id]/page.tsx
export async function generateStaticParams() {
const products = await getProducts();
// Only ids — the locale segment is left unenumerated.
return products.map((p) => ({ id: p.id }));
}
The build emits pages for the default locale only. Every other locale’s product pages are absent, and the host has nothing to serve.
The fix: enumerate the cross product
import { locales } from '@/i18n/config';
export async function generateStaticParams() {
const products = await getProducts();
// Every locale, every id — the full set of paths this route can serve.
return locales.flatMap((locale) =>
products.map((p) => ({ locale, id: p.id }))
);
}
The same applies at every level of the tree. A layout with a [locale] segment needs its own generateStaticParams returning every locale, and each nested dynamic segment needs the combination with everything above it. Missing one level produces a partially generated tree, which is harder to notice than a completely broken one.
Moving negotiation out of the application
With middleware gone, something else has to decide where a reader landing on / goes. The options differ in where the decision lives, and all of them are host-level.
A redirect rule on the host is the simplest: a rule that reads Accept-Language and redirects the bare root to a locale prefix. Most static hosts support this, and it reproduces exactly the behaviour described in locale negotiation strategies — negotiate once, at the entry point, then serve prefixed paths as plain files.
An edge function is the more capable version, where the host supports one. It can implement the full precedence order including a cookie, which a static redirect rule usually cannot.
A client-side redirect on a generated root page is the fallback when neither is available. It works, and it is the weakest option: it costs a round trip and a flash, and a crawler sees the redirect page rather than content.
What does not work is leaving the root unhandled. A static export with no root behaviour serves either a 404 or a page in one language to everyone, and the second is worse because it looks intentional.
The localized not-found page
A static export also changes what happens when a path genuinely does not exist. Next.js generates a not-found page, and with a locale segment there needs to be one per locale — otherwise a reader who mistypes a German URL gets an English error page, or nothing at all.
That means app/[locale]/not-found.tsx rather than a single root-level file, and it means the host must be configured to serve the right one. Most static hosts allow a per-prefix 404 mapping; where they do not, a single well-designed not-found page that offers a language selector is an acceptable compromise, and it is the same page that makes a reasonable x-default target as described in locale-aware SEO and hreflang.
Deciding whether a static export is the right model
The 404 is fixable, and it is worth asking the prior question, because a static export makes a trade that suits some localized products very well and others badly.
It suits a product whose localized content is known at build time and changes on a release cadence — documentation, marketing sites, catalogues that are regenerated when they change. For those, generating every locale is a strength: the pages are cheap to serve, identical for every reader, and cacheable indefinitely, which is exactly the property the caching rules in locale negotiation strategies are trying to reach by other means.
It suits a product badly when the localized surface depends on the reader. Regional pricing, authenticated content, anything that varies by market segment — those need request-time code, and reproducing them on the client costs a round trip and gives a crawler nothing to index.
The build-time cost is the third consideration. Generation time grows with routes multiplied by locales, and a product that is comfortable at five locales can find its pipeline uncomfortable at fifteen. That growth is predictable, which means it can be measured before it becomes a problem: multiply the current build time by the ratio of locales you expect, and decide with a number rather than a feeling.
Where the answer is mixed — most of the site static, a few routes needing request-time behaviour — hybrid rendering is the honest choice rather than forcing everything into one model. It keeps middleware available for the routes that need it, and keeps the cheap static serving for the routes that do not, at the cost of a deployment target that can run server code.
Verification
# The output tree must contain every locale for every route
find out -name '*.html' | sed -E 's#^out/([a-z]{2})/.*#\1#' | sort | uniq -c
# 412 de
# 412 en
# 412 fr ← identical counts, or something was not generated
# A localized dynamic route resolves to a real file
test -f out/de/produkte/42.html && echo present || echo MISSING
# The bare root redirects at the host, not in the app
curl -sI -H 'Accept-Language: de' https://example.com/ | grep -iE 'HTTP|location'
The first command is the one to keep in CI. Unequal per-locale counts mean a route was generated for some locales and not others, which is exactly the partial failure that reaches production unnoticed.
When to escalate
If some routes are generated and others are not, compare their generateStaticParams functions — the missing ones almost always enumerate a parameter without the locale, or inherit from a layout that does.
If the build succeeds but pages are empty rather than missing, the route is rendering without the data it expects. Static generation runs the data fetch at build time, so a fetch depending on a request-time value returns nothing and the page renders a shell.
If a locale needs request-time behaviour that cannot be moved to the host — geographic pricing, a personalised greeting — a static export is the wrong deployment model for that route. Hybrid rendering, where most routes are static and a few are server-rendered, is usually the answer, and it keeps the routing model in Next.js i18n routing setup intact.
FAQ
Can middleware be replaced by a client-side redirect?
It can, and the cost is a flash plus a round trip on the entry page. It also means crawlers see a redirect page rather than content, which affects how the site is indexed. Prefer a host-level rule wherever one is available.
Does the locale cookie still work?
It can be read on the client, so a switcher can remember a choice — but nothing on the server can act on it, so it cannot influence which file is served. In a static export the URL has to be the source of truth, which is a stronger version of the argument that already applies elsewhere.
How large does the output tree get?
Roughly the number of routes multiplied by the number of locales. That is fine at hundreds of pages and becomes a build-time problem at tens of thousands, at which point generating the most-visited locales statically and rendering the rest on demand is the usual compromise.
Do hreflang annotations still work?
Yes, and they matter more, because there is no negotiation to help a reader who lands in the wrong language. Every generated page should carry the full reciprocal set, which is straightforward since the locale list is already enumerated for generation.
Related
- Next.js i18n Routing Setup — the routing model a static export has to reproduce without middleware.
- Next.js App Router i18n middleware configuration — what the host-level rule is replacing.
- Locale Negotiation Strategies — the precedence order the redirect should implement.
- Locale-Aware SEO & hreflang — the annotations every generated page still needs.
Part of Next.js i18n Routing Setup.