Nuxt i18n redirect loop with prefix_except_default

The site works locally, deploys, and immediately answers every request with ERR_TOO_MANY_REDIRECTS. The network panel shows the shape: /about/de/about/about/de/about, forever, each hop a clean 302 with no error anywhere in the logs.

This is the most common Nuxt i18n failure, and it comes from one setting interacting with the prefix_except_default strategy in a way that seems harmless until it is deployed behind a cookie.

How redirectOn all produces a redirect loop An unprefixed path triggers detection, which reads the cookie and redirects to the prefixed path. Because detection is configured to run on all routes, the prefixed path triggers it again, and the second pass rewrites back toward the unprefixed form, so the two redirects chase each other. The loop, one hop at a time /about no prefix detect → de cookie says de /de/about prefixed redirectOn: all 302 detection runs again and rewrites back
Each hop is individually reasonable; the loop exists because nothing declares the decision final.

Root cause: detection is allowed to reconsider a settled decision

Under prefix_except_default the default locale lives at the bare path and every other locale is prefixed. That means /about is a valid, final URL — it is the English page — while /de/about is the German one. Both are answers, not questions.

detectBrowserLanguage.redirectOn decides where the module is allowed to run detection and redirect. Set to 'all', it runs on every route. So a reader with a German cookie visiting /about is redirected to /de/about, which is reasonable. The problem is what happens next: detection runs again on the prefixed route, and depending on the interaction between the cookie, the default locale and the strategy, it decides the canonical form of this page is the unprefixed one and rewrites back.

Neither hop is wrong in isolation. The loop exists because nothing in the configuration says “a URL that already names a locale is the final answer”.

Detection scope under the two redirectOn settings With redirectOn set to root, detection runs only on the bare root path and is skipped everywhere else. With it set to all, detection also runs on unprefixed pages, on already-prefixed pages — which is where the loop comes from — and on machine-facing routes where a locale has no meaning. When detection may run redirectOn: root redirectOn: all / (bare root) runs runs /about skipped runs /de/about skipped runs — the loop /api/health skipped runs — pointless
Only the first row genuinely needs detection: it is the one URL that carries no locale information.

A second, subtler contributor is alwaysRedirect: true. That setting makes the stored cookie outrank the URL, so even an explicitly requested locale is overridden by a previous preference — which means a reader can never reach a language other than their remembered one by following a link, and any attempt to do so bounces.

Minimal reproducible example

// nuxt.config.ts — the configuration that loops
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    strategy: 'prefix_except_default',
    defaultLocale: 'en',
    locales: [
      { code: 'en', language: 'en-GB', file: 'en.json' },
      { code: 'de', language: 'de-DE', file: 'de.json' },
    ],
    detectBrowserLanguage: {
      useCookie: true,
      redirectOn: 'all',        // ← runs on already-prefixed routes
      alwaysRedirect: true,     // ← cookie outranks the URL
    },
  },
});

Set the cookie to de, request /about, and the loop starts on the first response.

The fix: confine detection to the one URL that has no answer

detectBrowserLanguage: {
  useCookie: true,
  cookieKey: 'i18n_redirected',
  redirectOn: 'root',        // only the bare root is undecided
  alwaysRedirect: false,     // an explicit URL always wins
  fallbackLocale: 'en',
  cookieCrossOrigin: false,
  cookieSecure: true,
},

redirectOn: 'root' states the rule that was missing: detection is a way of answering “which locale does this reader want?” and that question only exists on a URL that does not already contain the answer. Every other path has decided.

alwaysRedirect: false makes explicit navigation authoritative. A reader who follows a link to /de/about gets the German page even if their cookie says English, which is what a link is for. The cookie still applies on the root path, so the preference is not lost — it is simply outranked by an explicit request.

Guard order for locale redirects Machine-facing routes pass through untouched. A path that already names a locale terminates immediately, because the URL has expressed the decision. Only the bare root reaches detection, which redirects once and stores the choice. A prefixed path is never reconsidered. The guard order that terminates 1 Is this a machine route? api, _nuxt, any path with a dot 2 Does the path already name a locale? if yes, stop — this is the answer 3 Is this the bare root? the only place detection belongs 4 Detect once, redirect once 302 and set i18n_redirected 5 Never reconsider a prefixed path the URL is the decision
The second step is the whole fix — an already-answered question must not be asked again.

Why it works locally and fails in production

Two environmental differences explain the pattern almost every team reports.

Cookies are the first. In development the cookie is frequently absent, so detection falls back to the browser language, agrees with the default locale, and redirects nowhere. The loop needs a cookie whose value disagrees with the path, which is exactly the state a returning production user has and a fresh dev session does not.

The edge is the second. A CDN or reverse proxy that adds its own locale rewrite — a country-based redirect, or a legacy rule mapping /de to a regional origin — is a second actor performing the same job. Even a correctly configured application will loop if something upstream is also rewriting, because each layer undoes the other. If the loop survives the configuration fix, capture the response chain at the edge and look for a location header the application did not emit.

A third, rarer cause is a service worker caching a redirect. Redirects are cacheable, and a cached 302 to a path that now redirects back produces a loop that persists after the fix is deployed. Clearing the site data confirms it in seconds and is worth ruling out before debugging anything else.

Reading the redirect chain

Debugging a loop is faster if you read the chain rather than the configuration. Three signatures cover nearly every case, and each points at a different layer.

Two alternating paths/about and /de/about repeating — is the application-level loop this page describes. Both hops carry your application’s response headers, and the fix is the detection scope.

A repeating single path/de/about redirecting to itself — is almost never the i18n module. It is a trailing-slash rule, an HTTPS upgrade, or a host normalisation running at the edge, and the give-away is that the location header is byte-identical to the request path apart from a slash or a scheme.

A chain that grows — three or four distinct paths before it repeats — means two rewriting layers are composing. A common shape is a country redirect at the CDN plus locale detection in the application, each correcting the other’s output. Capturing the chain with curl -sIL --max-redirs 10 and comparing the response headers of each hop shows which layer emitted which, because the edge and the origin rarely produce identical header sets.

The reason to classify before configuring is that the second and third signatures are unaffected by anything in detectBrowserLanguage. Changing the module configuration in response to an edge-level loop produces no improvement and a great deal of confusion, and it is the most common way an afternoon disappears into this bug.

Verification

The check is that exactly one redirect happens, and only from the root.

# The bare root redirects once, to the cookie's locale
curl -sI -b 'i18n_redirected=de' https://example.com/ | grep -E 'HTTP|location'
#   HTTP/2 302
#   location: /de/

# An explicit prefixed URL is final — no redirect, even with a conflicting cookie
curl -sI -b 'i18n_redirected=en' https://example.com/de/about | grep -E 'HTTP|location'
#   HTTP/2 200

# The default locale's bare path is also final
curl -sI -b 'i18n_redirected=de' https://example.com/about | grep -E 'HTTP|location'
#   HTTP/2 302
#   location: /de/about        ← one hop, then it stops

Add --max-redirs 3 to a smoke test in CI so a reintroduced loop fails the deployment rather than the reader.

When to escalate

If redirects terminate but land readers on the wrong locale, the problem has moved from termination to precedence, and the ordering rules in locale negotiation strategies are the reference — URL first, then explicit cookie, then header.

If the loop only affects some paths, compare them against the route table. A page that opted out with i18n: false, or one whose custom localized path is defined for some locales only, produces a route that exists in one locale and not another, and a redirect toward the missing form has nowhere to terminate.

If everything is correct and search engines still report redirect chains, they may be holding an older response. Redirect responses are cached aggressively by crawlers, and the fix propagates on their schedule rather than yours — which is one more reason the canonical annotations described in locale-aware SEO and hreflang matter while a fix is rolling out.

FAQ

Should the redirect be a 302 or a 301?

A 302. The mapping from the bare root to a locale depends on the reader, not on the resource, so it must never be cached as permanent — a 301 would pin one visitor’s language onto every subsequent visitor sharing a cache, and browsers hold 301s effectively forever.

Does no_prefix avoid this problem?

It avoids the loop by removing the localized URLs entirely, which is a large price. With no prefix there is nothing for detection to contradict, but there is also no shareable localized link and nothing for a crawler to index per language.

Why does the loop only appear for returning users?

Because it needs a cookie that disagrees with the requested path. A first-time visitor has no cookie, so detection falls back to the header, usually agrees with the URL, and terminates. The cookie is written by the first redirect, which is why the second visit is the one that breaks.

Can I keep redirectOn: 'all' and fix it another way?

You can guard it in middleware by returning early when the first path segment is a supported locale, which is the same guard Next.js applications need. It works, but it duplicates in application code a rule the module already expresses with one setting — so it is worth doing only if you have a specific reason to detect on non-root paths.

Part of Nuxt i18n Module Setup.