From 41c1891b9cdb2185cf9ca5c3440d5ff040649e28 Mon Sep 17 00:00:00 2001 From: RobVermeer Date: Tue, 9 Jun 2026 16:28:05 +0200 Subject: [PATCH] Resolve App Router routes when Pages Router i18n domains are configured When the Pages Router `i18n` config is set (e.g. with domain routing), the locale prefix was stripped from every request before route matching. App Router routes whose first segment is the locale (e.g. `app/[lang]/test`) therefore could never match a locale-prefixed pathname such as `/nl-NL/test` (whether reached directly or via a `proxy.ts` rewrite of `/test`), and returned 404. Statically prerendering such routes also failed the build with "export path doesn't match the page". App Router routes are not locale-aware: the locale is part of the route path itself, so the Pages Router locale machinery must not strip it for them. This is fixed at the three points where the locale prefix was being removed: - resolve-routes (dev/prod routing): App Router dynamic routes are now also matched against the full, un-stripped pathname so a leading `[lang]` segment can capture the locale. Pages Router routes still match the stripped path. - base-server (invoke path handling): when the resolved output is an App Router route that actually consumes the locale prefix, the locale prefix is kept in the pathname (the basePath is still stripped) and the locale is treated as explicit, instead of being stripped like a Pages Router route. - export worker (static generation): App Router export paths keep their locale prefix and match params against the full path. All changes are gated so non-i18n apps and Pages Router behavior are unaffected. Adds e2e fixtures covering both routers under domain-based i18n across direct and proxy-rewritten access, with and without a basePath. Fixes #86048 --- packages/next/src/export/worker.ts | 11 ++- packages/next/src/server/base-server.ts | 47 +++++++++- .../server/lib/router-utils/resolve-routes.ts | 46 ++++++++-- .../app/[lang]/test/page.tsx | 16 ++++ .../app/layout.tsx | 11 +++ .../i18n-app-pages-domain-base-path.test.ts | 42 +++++++++ .../next.config.js | 23 +++++ .../pages/index.tsx | 18 ++++ .../i18n-app-pages-domain-base-path/proxy.ts | 48 ++++++++++ .../app/[lang]/blog/[slug]/page.tsx | 15 ++++ .../app/[lang]/layout.tsx | 13 +++ .../app/[lang]/test/page.tsx | 19 ++++ test/e2e/i18n-app-pages-domain/app/layout.tsx | 11 +++ .../i18n-app-pages-domain.test.ts | 87 +++++++++++++++++++ test/e2e/i18n-app-pages-domain/next.config.js | 23 +++++ .../e2e/i18n-app-pages-domain/pages/index.tsx | 23 +++++ test/e2e/i18n-app-pages-domain/proxy.ts | 51 +++++++++++ 17 files changed, 491 insertions(+), 13 deletions(-) create mode 100644 test/e2e/i18n-app-pages-domain-base-path/app/[lang]/test/page.tsx create mode 100644 test/e2e/i18n-app-pages-domain-base-path/app/layout.tsx create mode 100644 test/e2e/i18n-app-pages-domain-base-path/i18n-app-pages-domain-base-path.test.ts create mode 100644 test/e2e/i18n-app-pages-domain-base-path/next.config.js create mode 100644 test/e2e/i18n-app-pages-domain-base-path/pages/index.tsx create mode 100644 test/e2e/i18n-app-pages-domain-base-path/proxy.ts create mode 100644 test/e2e/i18n-app-pages-domain/app/[lang]/blog/[slug]/page.tsx create mode 100644 test/e2e/i18n-app-pages-domain/app/[lang]/layout.tsx create mode 100644 test/e2e/i18n-app-pages-domain/app/[lang]/test/page.tsx create mode 100644 test/e2e/i18n-app-pages-domain/app/layout.tsx create mode 100644 test/e2e/i18n-app-pages-domain/i18n-app-pages-domain.test.ts create mode 100644 test/e2e/i18n-app-pages-domain/next.config.js create mode 100644 test/e2e/i18n-app-pages-domain/pages/index.tsx create mode 100644 test/e2e/i18n-app-pages-domain/proxy.ts diff --git a/packages/next/src/export/worker.ts b/packages/next/src/export/worker.ts index d69b0a5d1506..f66c9844f14e 100644 --- a/packages/next/src/export/worker.ts +++ b/packages/next/src/export/worker.ts @@ -137,7 +137,11 @@ async function exportPageImpl( let updatedPath = exportPath._ssgPath || path let locale = exportPath._locale || commonRenderOpts.locale - if (commonRenderOpts.locale) { + // App Router routes are not locale-aware: when a locale prefix is present it + // is part of the route itself (e.g. `/[lang]/...`) and must be preserved. + // Only the Pages Router strips the locale prefix from the export path. + // See https://github.com/vercel/next.js/issues/86048 + if (commonRenderOpts.locale && !isAppDir) { const localePathResult = normalizeLocalePath(path, commonRenderOpts.locales) if (localePathResult.detectedLocale) { @@ -161,7 +165,10 @@ async function exportPageImpl( if (isDynamic && page !== nonLocalizedPath) { const normalizedPage = isAppDir ? normalizeAppPath(page) : page - params = getParams(normalizedPage, updatedPath) + // For App Router routes match against the full path so a leading dynamic + // segment (e.g. `[lang]`) can capture the locale prefix; Pages Router + // routes match against the locale-stripped path. + params = getParams(normalizedPage, isAppDir ? path : updatedPath) } const { req, res } = createRequestResponseMocks({ url: updatedPath }) diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 214d0a07aa9f..529cddea22cc 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -58,6 +58,8 @@ import { UNDERSCORE_NOT_FOUND_ROUTE_ENTRY, } from '../shared/lib/constants' import { isDynamicRoute } from '../shared/lib/router/utils' +import { getRouteMatcher } from '../shared/lib/router/utils/route-matcher' +import { getRouteRegex } from '../shared/lib/router/utils/route-regex' import { execOnce } from '../shared/lib/utils' import { isBlockedPage } from './utils' import { getBotType, isBot } from '../shared/lib/router/utils/is-bot' @@ -1542,15 +1544,56 @@ export default abstract class Server< parsedUrl.pathname = parsedMatchedPath.pathname addRequestMeta(req, 'rewrittenPathname', invokePathnameInfo.pathname) } + const pathnameNoBasePath = removePathPrefix( + parsedUrl.pathname, + this.nextConfig.basePath || '' + ) const normalizeResult = normalizeLocalePath( - removePathPrefix(parsedUrl.pathname, this.nextConfig.basePath || ''), + pathnameNoBasePath, this.nextConfig.i18n?.locales ) if (normalizeResult.detectedLocale) { addRequestMeta(req, 'locale', normalizeResult.detectedLocale) } - parsedUrl.pathname = normalizeResult.pathname + + // App Router routes are not locale-aware: a leading dynamic segment + // such as `[lang]` is meant to capture the locale prefix itself. When + // the resolved App Router route actually consumes the locale prefix, + // keep it in the pathname (and treat the locale as explicit) instead of + // stripping it like a Pages Router route. App Router routes that don't + // model the locale (e.g. `/about`, `/items/[id]`) still get the locale + // stripped. See https://github.com/vercel/next.js/issues/86048 + const invokeOutput = getRequestMeta(req, 'invokeOutput') + let appRouteConsumesLocale = false + + if ( + typeof invokeOutput === 'string' && + normalizeResult.detectedLocale && + this.appPathRoutes?.[invokeOutput] && + isDynamicRoute(invokeOutput) + ) { + try { + const appRouteMatch = getRouteMatcher(getRouteRegex(invokeOutput))( + pathnameNoBasePath + ) + appRouteConsumesLocale = Boolean(appRouteMatch) + } catch { + appRouteConsumesLocale = false + } + } + + if (appRouteConsumesLocale) { + // Keep the locale prefix (it's a real route segment for this App + // Router route) but still strip the basePath, so downstream rendering + // receives a basePath-free, locale-prefixed pathname. + parsedUrl.pathname = pathnameNoBasePath + // The locale is an explicit route segment, so it should not be + // treated as inferred from the default locale. + removeRequestMeta(req, 'localeInferredFromDefault') + } else { + parsedUrl.pathname = normalizeResult.pathname + } for (const key of Object.keys(parsedUrl.query)) { delete parsedUrl.query[key] diff --git a/packages/next/src/server/lib/router-utils/resolve-routes.ts b/packages/next/src/server/lib/router-utils/resolve-routes.ts index 1d4856af176d..f81b5415bbed 100644 --- a/packages/next/src/server/lib/router-utils/resolve-routes.ts +++ b/packages/next/src/server/lib/router-utils/resolve-routes.ts @@ -309,6 +309,14 @@ export function getResolveRoutes( } const localeResult = fsChecker.handleLocale(curPathname || '') + // Whether the pathname carries a locale prefix that `handleLocale` + // stripped. App Router routes are not locale-aware: when the locale is + // part of the route itself (e.g. `/[lang]/...`) it can only be captured + // by matching against the full, un-stripped pathname. + // See https://github.com/vercel/next.js/issues/86048 + const hasStrippedLocale = + Boolean(config.i18n) && localeResult.pathname !== curPathname + for (const route of dynamicRoutes) { // when resolving fallback: false the // render worker may return a no-fallback response @@ -318,19 +326,39 @@ export function getResolveRoutes( if (invokedOutputs?.has(route.page)) { continue } - const params = route.match(localeResult.pathname) - if (params) { - const pageOutput = await fsChecker.getItem( + // Pages Router routes (and App Router routes that don't model the + // locale) match against the locale-stripped pathname. + let params = route.match(localeResult.pathname) + let pageOutput: Awaited> = null + + // For App Router routes, also try the full (locale-prefixed) pathname + // so a leading dynamic segment such as `[lang]` can capture the locale. + // This only happens when a locale prefix was actually stripped, so it + // doesn't affect non-i18n apps or routes that don't carry a locale. + if (hasStrippedLocale) { + pageOutput = await fsChecker.getItem( addPathPrefix(route.page, config.basePath || '') ) - // i18n locales aren't matched for app dir - if ( - pageOutput?.type === 'appFile' && - initialLocaleResult?.detectedLocale - ) { - continue + if (pageOutput?.type === 'appFile') { + const fullParams = route.match(curPathname || '') + if (fullParams) { + params = fullParams + } else if (params && initialLocaleResult?.detectedLocale) { + // i18n locales aren't matched for app dir: when the request + // already carried an explicit locale prefix, an App Router route + // that only matches the locale-stripped pathname is not served. + params = false + } + } + } + + if (params) { + if (!pageOutput) { + pageOutput = await fsChecker.getItem( + addPathPrefix(route.page, config.basePath || '') + ) } if (pageOutput && curPathname?.startsWith('/_next/data')) { diff --git a/test/e2e/i18n-app-pages-domain-base-path/app/[lang]/test/page.tsx b/test/e2e/i18n-app-pages-domain-base-path/app/[lang]/test/page.tsx new file mode 100644 index 000000000000..40c15d8712d5 --- /dev/null +++ b/test/e2e/i18n-app-pages-domain-base-path/app/[lang]/test/page.tsx @@ -0,0 +1,16 @@ +export const dynamic = 'force-dynamic' + +export default async function TestPage({ + params, +}: { + params: Promise<{ lang: string }> +}) { + const { lang } = await params + + return ( +
+

App Router Test Page

+

{lang}

+
+ ) +} diff --git a/test/e2e/i18n-app-pages-domain-base-path/app/layout.tsx b/test/e2e/i18n-app-pages-domain-base-path/app/layout.tsx new file mode 100644 index 000000000000..dbce4ea8e3ae --- /dev/null +++ b/test/e2e/i18n-app-pages-domain-base-path/app/layout.tsx @@ -0,0 +1,11 @@ +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/e2e/i18n-app-pages-domain-base-path/i18n-app-pages-domain-base-path.test.ts b/test/e2e/i18n-app-pages-domain-base-path/i18n-app-pages-domain-base-path.test.ts new file mode 100644 index 000000000000..f8604f7bd312 --- /dev/null +++ b/test/e2e/i18n-app-pages-domain-base-path/i18n-app-pages-domain-base-path.test.ts @@ -0,0 +1,42 @@ +import { nextTestSetup } from 'e2e-utils' +import { fetchViaHTTP } from 'next-test-utils' +import cheerio from 'cheerio' + +/** + * Regression test for https://github.com/vercel/next.js/issues/86048 with a + * configured `basePath`. The locale prefix must be preserved for App Router + * routes, while the basePath is still stripped before downstream rendering + * (i.e. the matcher must receive `/en-US/test`, not `/docs/en-US/test`). + */ +describe('i18n-app-pages-domain-base-path', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + const fetch = (path: string, host: string) => + fetchViaHTTP(next.appPort, path, undefined, { headers: { host } }) + + it('serves the Pages Router home under basePath', async () => { + const res = await fetch('/docs', 'nl.example.local') + expect(res.status).toBe(200) + const $ = cheerio.load(await res.text()) + expect($('#pages-home').text()).toBe('Pages Router Home') + expect($('#pages-locale').text()).toBe('nl-NL') + }) + + it('serves the App Router /test page via proxy rewrite under basePath', async () => { + const res = await fetch('/docs/test', 'nl.example.local') + expect(res.status).toBe(200) + const $ = cheerio.load(await res.text()) + expect($('#test-page').text()).toBe('App Router Test Page') + expect($('#test-locale').text()).toBe('nl-NL') + }) + + it('serves the App Router /test page via a direct locale-prefixed URL under basePath', async () => { + const res = await fetch('/docs/en-US/test', 'en.example.local') + expect(res.status).toBe(200) + const $ = cheerio.load(await res.text()) + expect($('#test-page').text()).toBe('App Router Test Page') + expect($('#test-locale').text()).toBe('en-US') + }) +}) diff --git a/test/e2e/i18n-app-pages-domain-base-path/next.config.js b/test/e2e/i18n-app-pages-domain-base-path/next.config.js new file mode 100644 index 000000000000..58b9688eeaeb --- /dev/null +++ b/test/e2e/i18n-app-pages-domain-base-path/next.config.js @@ -0,0 +1,23 @@ +/** + * Reproduction for https://github.com/vercel/next.js/issues/86048 with a + * `basePath` configured. Ensures the locale prefix is preserved for App Router + * routes while the basePath is still stripped before downstream rendering. + */ +module.exports = { + basePath: '/docs', + i18n: { + locales: ['en-US', 'nl-NL'], + defaultLocale: 'en-US', + localeDetection: false, + domains: [ + { + domain: 'en.example.local', + defaultLocale: 'en-US', + }, + { + domain: 'nl.example.local', + defaultLocale: 'nl-NL', + }, + ], + }, +} diff --git a/test/e2e/i18n-app-pages-domain-base-path/pages/index.tsx b/test/e2e/i18n-app-pages-domain-base-path/pages/index.tsx new file mode 100644 index 000000000000..8eb5f4c4e396 --- /dev/null +++ b/test/e2e/i18n-app-pages-domain-base-path/pages/index.tsx @@ -0,0 +1,18 @@ +import type { GetServerSideProps } from 'next' + +type Props = { locale: string } + +export default function HomePage({ locale }: Props) { + return ( +
+

Pages Router Home

+

{locale}

+
+ ) +} + +export const getServerSideProps: GetServerSideProps = async ( + context +) => { + return { props: { locale: context.locale || 'en-US' } } +} diff --git a/test/e2e/i18n-app-pages-domain-base-path/proxy.ts b/test/e2e/i18n-app-pages-domain-base-path/proxy.ts new file mode 100644 index 000000000000..3da1b45076a9 --- /dev/null +++ b/test/e2e/i18n-app-pages-domain-base-path/proxy.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server' + +const LOCALE_DOMAINS: Record = { + 'en.example.local': 'en-US', + 'nl.example.local': 'nl-NL', +} +const DEFAULT_LOCALE = 'en-US' +const LOCALES = ['en-US', 'nl-NL'] + +function getLocaleFromHost(host: string | null): string { + if (!host) return DEFAULT_LOCALE + const hostname = host.split(':')[0] + return LOCALE_DOMAINS[hostname] || DEFAULT_LOCALE +} + +// `request.nextUrl.pathname` is already basePath-stripped here, so the matcher +// and rewrite are written without the basePath. +export function proxy(request: NextRequest) { + const { pathname } = request.nextUrl + + if (pathname === '/') { + return NextResponse.next() + } + + if ( + pathname.startsWith('/_next') || + pathname.startsWith('/api') || + pathname.includes('.') + ) { + return NextResponse.next() + } + + const hasLocale = LOCALES.some( + (locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`) + ) + if (hasLocale) { + return NextResponse.next() + } + + const locale = getLocaleFromHost(request.headers.get('host')) + const url = request.nextUrl.clone() + url.pathname = `/${locale}${pathname}` + return NextResponse.rewrite(url) +} + +export const config = { + matcher: ['/test/:path*'], +} diff --git a/test/e2e/i18n-app-pages-domain/app/[lang]/blog/[slug]/page.tsx b/test/e2e/i18n-app-pages-domain/app/[lang]/blog/[slug]/page.tsx new file mode 100644 index 000000000000..5e21ee505880 --- /dev/null +++ b/test/e2e/i18n-app-pages-domain/app/[lang]/blog/[slug]/page.tsx @@ -0,0 +1,15 @@ +export default async function BlogPostPage({ + params, +}: { + params: Promise<{ lang: string; slug: string }> +}) { + const { lang, slug } = await params + + return ( +
+

App Router Blog Post

+

{lang}

+

{slug}

+
+ ) +} diff --git a/test/e2e/i18n-app-pages-domain/app/[lang]/layout.tsx b/test/e2e/i18n-app-pages-domain/app/[lang]/layout.tsx new file mode 100644 index 000000000000..e3e41d732e2f --- /dev/null +++ b/test/e2e/i18n-app-pages-domain/app/[lang]/layout.tsx @@ -0,0 +1,13 @@ +export const dynamicParams = true + +export function generateStaticParams() { + return [{ lang: 'en-US' }, { lang: 'nl-NL' }] +} + +export default function LangLayout({ + children, +}: { + children: React.ReactNode +}) { + return children +} diff --git a/test/e2e/i18n-app-pages-domain/app/[lang]/test/page.tsx b/test/e2e/i18n-app-pages-domain/app/[lang]/test/page.tsx new file mode 100644 index 000000000000..5506f17e6d13 --- /dev/null +++ b/test/e2e/i18n-app-pages-domain/app/[lang]/test/page.tsx @@ -0,0 +1,19 @@ +export default async function TestPage({ + params, +}: { + params: Promise<{ lang: string }> +}) { + const { lang } = await params + + return ( +
+

App Router Test Page

+

{lang}

+

+ {lang === 'nl-NL' + ? 'Dit is de Nederlandse versie' + : 'This is the English version'} +

+
+ ) +} diff --git a/test/e2e/i18n-app-pages-domain/app/layout.tsx b/test/e2e/i18n-app-pages-domain/app/layout.tsx new file mode 100644 index 000000000000..dbce4ea8e3ae --- /dev/null +++ b/test/e2e/i18n-app-pages-domain/app/layout.tsx @@ -0,0 +1,11 @@ +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/test/e2e/i18n-app-pages-domain/i18n-app-pages-domain.test.ts b/test/e2e/i18n-app-pages-domain/i18n-app-pages-domain.test.ts new file mode 100644 index 000000000000..f35668ac30f9 --- /dev/null +++ b/test/e2e/i18n-app-pages-domain/i18n-app-pages-domain.test.ts @@ -0,0 +1,87 @@ +import { nextTestSetup } from 'e2e-utils' +import { fetchViaHTTP } from 'next-test-utils' +import cheerio from 'cheerio' + +/** + * Regression test for https://github.com/vercel/next.js/issues/86048 + * + * When the Pages Router `i18n` config uses domain-based routing, App Router + * routes that rely on a dynamic `[lang]` segment must still resolve. The + * Pages Router locale-prefix stripping previously clobbered App Router routes + * whose first path segment is the locale, making them 404. + */ +describe('i18n-app-pages-domain', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + const fetch = (path: string, host: string) => + fetchViaHTTP(next.appPort, path, undefined, { headers: { host } }) + + describe('Pages Router', () => { + it('serves the Pages Router home for the English domain', async () => { + const res = await fetch('/', 'en.example.local') + expect(res.status).toBe(200) + const $ = cheerio.load(await res.text()) + expect($('#pages-home').text()).toBe('Pages Router Home') + expect($('#pages-locale').text()).toBe('en-US') + expect($('#pages-message').text()).toBe('Welcome to the homepage') + }) + + it('serves the Pages Router home for the Dutch domain', async () => { + const res = await fetch('/', 'nl.example.local') + expect(res.status).toBe(200) + const $ = cheerio.load(await res.text()) + expect($('#pages-home').text()).toBe('Pages Router Home') + expect($('#pages-locale').text()).toBe('nl-NL') + expect($('#pages-message').text()).toBe('Welkom op de homepagina') + }) + }) + + describe('App Router via proxy rewrite', () => { + it('serves the App Router /test page for the English domain', async () => { + const res = await fetch('/test', 'en.example.local') + expect(res.status).toBe(200) + const $ = cheerio.load(await res.text()) + expect($('#test-page').text()).toBe('App Router Test Page') + expect($('#test-locale').text()).toBe('en-US') + expect($('#test-message').text()).toBe('This is the English version') + }) + + it('serves the App Router /test page for the Dutch domain', async () => { + const res = await fetch('/test', 'nl.example.local') + expect(res.status).toBe(200) + const $ = cheerio.load(await res.text()) + expect($('#test-page').text()).toBe('App Router Test Page') + expect($('#test-locale').text()).toBe('nl-NL') + expect($('#test-message').text()).toBe('Dit is de Nederlandse versie') + }) + }) + + describe('App Router via direct locale-prefixed URL', () => { + it('serves /en-US/test directly', async () => { + const res = await fetch('/en-US/test', 'en.example.local') + expect(res.status).toBe(200) + const $ = cheerio.load(await res.text()) + expect($('#test-page').text()).toBe('App Router Test Page') + expect($('#test-locale').text()).toBe('en-US') + }) + + it('serves /nl-NL/test directly', async () => { + const res = await fetch('/nl-NL/test', 'nl.example.local') + expect(res.status).toBe(200) + const $ = cheerio.load(await res.text()) + expect($('#test-page').text()).toBe('App Router Test Page') + expect($('#test-locale').text()).toBe('nl-NL') + }) + + it('serves a nested route with multiple dynamic segments', async () => { + const res = await fetch('/nl-NL/blog/my-post', 'nl.example.local') + expect(res.status).toBe(200) + const $ = cheerio.load(await res.text()) + expect($('#blog-page').text()).toBe('App Router Blog Post') + expect($('#blog-locale').text()).toBe('nl-NL') + expect($('#blog-slug').text()).toBe('my-post') + }) + }) +}) diff --git a/test/e2e/i18n-app-pages-domain/next.config.js b/test/e2e/i18n-app-pages-domain/next.config.js new file mode 100644 index 000000000000..35c4c607e894 --- /dev/null +++ b/test/e2e/i18n-app-pages-domain/next.config.js @@ -0,0 +1,23 @@ +/** + * Reproduction for https://github.com/vercel/next.js/issues/86048 + * + * Pages Router `i18n` config with domain-based routing must not prevent + * App Router routes (that use a dynamic `[lang]` segment) from resolving. + */ +module.exports = { + i18n: { + locales: ['en-US', 'nl-NL'], + defaultLocale: 'en-US', + localeDetection: false, + domains: [ + { + domain: 'en.example.local', + defaultLocale: 'en-US', + }, + { + domain: 'nl.example.local', + defaultLocale: 'nl-NL', + }, + ], + }, +} diff --git a/test/e2e/i18n-app-pages-domain/pages/index.tsx b/test/e2e/i18n-app-pages-domain/pages/index.tsx new file mode 100644 index 000000000000..d6bcaa72f6ce --- /dev/null +++ b/test/e2e/i18n-app-pages-domain/pages/index.tsx @@ -0,0 +1,23 @@ +import type { GetServerSideProps } from 'next' + +type Props = { locale: string } + +export default function HomePage({ locale }: Props) { + return ( +
+

Pages Router Home

+

{locale}

+

+ {locale === 'nl-NL' + ? 'Welkom op de homepagina' + : 'Welcome to the homepage'} +

+
+ ) +} + +export const getServerSideProps: GetServerSideProps = async ( + context +) => { + return { props: { locale: context.locale || 'en-US' } } +} diff --git a/test/e2e/i18n-app-pages-domain/proxy.ts b/test/e2e/i18n-app-pages-domain/proxy.ts new file mode 100644 index 000000000000..a9360949d522 --- /dev/null +++ b/test/e2e/i18n-app-pages-domain/proxy.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server' + +const LOCALE_DOMAINS: Record = { + 'en.example.local': 'en-US', + 'nl.example.local': 'nl-NL', +} +const DEFAULT_LOCALE = 'en-US' +const LOCALES = ['en-US', 'nl-NL'] + +function getLocaleFromHost(host: string | null): string { + if (!host) return DEFAULT_LOCALE + const hostname = host.split(':')[0] + return LOCALE_DOMAINS[hostname] || DEFAULT_LOCALE +} + +export function proxy(request: NextRequest) { + const { pathname } = request.nextUrl + + // Let the Pages Router handle the root path. + if (pathname === '/') { + return NextResponse.next() + } + + // Skip Next.js internals, API routes and files with extensions. + if ( + pathname.startsWith('/_next') || + pathname.startsWith('/api') || + pathname.includes('.') + ) { + return NextResponse.next() + } + + // If the path is already locale-prefixed, leave it alone. + const hasLocale = LOCALES.some( + (locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`) + ) + if (hasLocale) { + return NextResponse.next() + } + + // Rewrite e.g. `/test` -> `/nl-NL/test` so the App Router `[lang]` segment + // can capture the locale derived from the domain. + const locale = getLocaleFromHost(request.headers.get('host')) + const url = request.nextUrl.clone() + url.pathname = `/${locale}${pathname}` + return NextResponse.rewrite(url) +} + +export const config = { + matcher: ['/test/:path*'], +}