Skip to content

fix(config): match i18n headers() only in their locale-prefixed form - #4088

Merged
james-elicx merged 4 commits into
mainfrom
fix/i18n-header-locale-fallback
Oct 19, 2026
Merged

james-elicx merged 4 commits into
mainfrom
fix/i18n-header-locale-fallback

Conversation

@james-elicx

@james-elicx james-elicx commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Summary

With i18n configured, applyLocaleToRoutes gave every headers() rule two sources: the /:nextInternalLocale(...) form and an unprefixed fallback copy. Next.js 16.2.7 (load-custom-routes.js processRoutes) emits only the locale-prefixed form. It matches against the request path with the default locale spliced in (resolve-routes.js), so /about is matched as /en/about.

vinext already splices the default locale into the path at every matchHeaders call site (normalizeDefaultLocalePathname), so the fallback could only over-match:

  • A negative lookahead such as /((?!api).*) matched /fr/api/x through the fallback, because the lookahead saw fr/api/x. Next.js excludes that path.
  • Default-locale requests matched both forms, so additive headers such as Set-Cookie were appended twice.

This PR stops emitting the fallback for header rules. Redirects and rewrites are unchanged.

Next.js also re-adds the root's trailing slash after splicing in the locale (/ becomes /en/), but only when the raw request path (basePath included) ended with /, trailingSlash is on and skipProxyUrlNormalize is off (hadTrailingSlash + maybeAddTrailingSlash). Header call sites now compute that same condition with shouldRestoreLocaleRootTrailingSlash, so root rules keep matching where Next.js applies them now that the fallback no longer covers it. Redirect and rewrite callers don't pass it.

Behaviour changes, each matching real Next.js 16.2.7 (next build --webpack + next start probe):

  • /fr/api/x and /en/api/x no longer get /((?!api).*) headers.
  • /about, /en/about and /fr/about get them exactly once.
  • With trailingSlash off, / is matched as /en, so /((?!api).*) no longer applies to it. A locale: false /en rule does.
  • /_next/* paths only match locale: false rules.

Known limitations

These are out of scope for this PR and not changed:

  • Redirects and rewrites still keep the unprefixed fallback, so the same lookahead over-match applies to them.
  • Redirect and rewrite callers don't restore the root trailing slash.
  • /_next/data requests match headers against the normalized page path (already the case before this PR), so their root never gets the trailing slash Next.js gives the data-normalized index under trailingSlash with middleware.

Tests

  • tests/shims.test.ts ("default-locale path normalisation"):
    • header rules are emitted prefixed-only;
    • matchHeaders parity for lookaheads under every locale;
    • a single Set-Cookie for default-locale and domain-locale paths;
    • the /, locale: false and /_next/* cases;
    • root under trailingSlash: true: /, a basePath root with and without a slash, skipProxyUrlNormalize: true.
  • E2E on the existing pages-i18n-public-rewrite fixture (tests/e2e/pages-router/i18n-locale-case-config.spec.ts):
    • a query-gated /((?!api).*) rule with a header and a Set-Cookie;
    • asserts which paths get them and that each applies once.

Next.js processRoutes gives header rules only the
`/:nextInternalLocale(...)` source and matches them against the
default-locale-prefixed pathname. vinext also kept the unprefixed source,
so `/((?!api).*)` applied to `/fr/api/x` and default-locale requests
matched twice, appending Set-Cookie/Vary twice.

Drop the unprefixed copy for headers (every header matcher caller already
normalises the pathname) and keep the root's trailing slash when splicing
in the default locale under `trailingSlash: true`, like resolve-routes.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review this PR. You have an 8-minute time limit. Head SHA: 82e05c0

@pkg-pr-new

pkg-pr-new Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@vinext/cloudflare

npm i https://pkg.pr.new/@vinext/cloudflare@4088

create-vinext-app

npm i https://pkg.pr.new/create-vinext-app@4088

@vinext/types

npm i https://pkg.pr.new/@vinext/types@4088

vinext

npm i https://pkg.pr.new/vinext@4088

@cloudflare/workers-response-store

npm i https://pkg.pr.new/@cloudflare/workers-response-store@4088

commit: 799d626

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
response-store-demo preview production
workers-cache preview production
kv preview production
static-assets-cache preview production
static-assets-pages preview production
static-assets-pages-i18n preview production
static-export preview production
web preview production

@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 799d626 against base 55dcb4c using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 7 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 154.2 KB 154.2 KB ⚫ +0.0%
Client entry size (gzip) vinext 141.7 KB 141.7 KB ⚫ +0.0%
Dev server cold start vinext 2.75 s 2.73 s ⚫ -0.7%
Dev server cold start (large Pages Router app) vinext 23.83 s 23.73 s ⚫ -0.4%
Production build time vinext 3.33 s 3.32 s ⚫ -0.1%
RSC entry closure size (gzip) vinext 141.9 KB 142.0 KB ⚫ +0.1%
Server bundle size (gzip) vinext 249.4 KB 249.5 KB ⚫ +0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

const defaultLocale = domainLocale?.defaultLocale ?? i18n.defaultLocale;

if (pathname === "/") return `/${defaultLocale}`;
if (pathname === "/") return options.trailingSlash ? `/${defaultLocale}/` : `/${defaultLocale}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: Next.js does not restore this slash from trailingSlash alone. In v16.2.7 resolve-routes.ts, it records whether the original request pathname ended in /, then calls maybeAddTrailingSlash only when that is true and skipProxyUrlNormalize is false. Here every basePath-stripped root becomes /en/ whenever trailingSlash is enabled. Consequently, with skipProxyUrlNormalize: true (or an exact /docs request under basePath: "/docs" plus skipTrailingSlashRedirect: true), a root header transformed to /:nextInternalLocale(...)/ applies in vinext even though Next.js matches /en and skips it. Please thread the original trailing-slash state and normalization gate through these header call sites rather than inferring preservation solely from the config flag.

@ask-bonk

ask-bonk Bot commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Review: 1 findings.

Posted 1 actionable inline comment.

github run

resolve-routes re-adds the root's trailing slash after splicing in the
default locale only when the raw request pathname (basePath included)
ended with `/`, trailingSlash is on, and skipProxyUrlNormalize is off.
Header matching restored it whenever trailingSlash was set, so `/docs`
under basePath "/docs" and skipProxyUrlNormalize requests matched as
`/en/` instead of `/en`.

Each header call site now computes the flag from its raw pathname with
shouldRestoreLocaleRootTrailingSlash; skipProxyUrlNormalize is threaded
into the App RSC handler.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review this PR. You have an 8-minute time limit. Head SHA: 91b0fe4

basePath: options.basePath,
configHeaders: options.configHeaders,
i18nConfig: options.i18nConfig,
restoreLocaleRootTrailingSlash: shouldRestoreLocaleRootTrailingSlash(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: This derives the flag from the already-normalized request URL. In this hybrid Pages fallback path, lines 3079-3143 rewrite /_next/data/<buildId>/index.json to / before the finalizer runs, so with i18n and trailingSlash: true this returns true even when there is no middleware and applies a root rule as /en/. Next.js only performs the pre-header _next/data normalization in its middleware_next_data branch when middleware matchers exist; without middleware the locale-prefixed root header does not match the raw data URL. Please carry the pre-normalization decision/raw pathname through this path instead of recomputing it from request.url.

@ask-bonk

ask-bonk Bot commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Since last review: 1 resolved, 0 still open, 1 new.

Posted 1 actionable inline comment.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review this PR. You have an 8-minute time limit. Head SHA: 799d626

@ask-bonk

ask-bonk Bot commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Since last review: 1 resolved, 0 still open, 0 new.
LGTM!

github run

@james-elicx
james-elicx marked this pull request as ready for review October 19, 2026 08:38
@james-elicx
james-elicx merged commit da0f859 into main Oct 19, 2026
104 checks passed
@james-elicx
james-elicx deleted the fix/i18n-header-locale-fallback branch October 19, 2026 08:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant