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*'], +}