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.

Capabilities a static export gives up Under a static export middleware never runs, request headers are unavailable, dynamic routes exist only for the parameters generated at build time, and redirects have to be configured on the host rather than emitted by the application. What a static export removes Server render Static export Middleware runs per request never runs Request headers available unavailable Dynamic routes resolved on demand only what was generated Redirects emitted by the app the host emits them
The first two rows are why locale negotiation stops working; the third is why the 404 appears.

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.

A static host serving only what was generated A reader requests a localized dynamic route, the host looks for a file at that exact path, the build never generated it because the locale and parameter combination was not enumerated, and the host answers 404 because there is no application to ask. Where the 404 comes from Reader Static host Build output Result GET /de/produkte/42 look for de/produkte/42.html not generated 404
There is no runtime to fall back to — a static host serves files, and the file is not there.

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.

Five steps for a fully generated localized export Every locale is combined with every parameter value to form the full cross product, those pairs are returned from the static params function for each dynamic segment, locale negotiation moves to a host-level redirect on the bare root, a not-found page is generated per locale, and continuous integration asserts that the output tree contains one file per locale per route. Generating every localized path 1 Enumerate locales and parameters together the cross product, not one of them 2 Return them from generateStaticParams per dynamic segment 3 Move negotiation to the host a redirect rule on the bare root 4 Emit a localized 404 per locale so a miss stays in the language 5 Assert the output tree in CI one file per locale per route
Step one is where the bug lives: enumerating locales or parameters, but not both.

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.

Part of Next.js i18n Routing Setup.