Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions packages/next/src/export/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 })
Expand Down
47 changes: 45 additions & 2 deletions packages/next/src/server/base-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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')
Comment thread
vercel[bot] marked this conversation as resolved.
} else {
parsedUrl.pathname = normalizeResult.pathname
}

for (const key of Object.keys(parsedUrl.query)) {
delete parsedUrl.query[key]
Expand Down
46 changes: 37 additions & 9 deletions packages/next/src/server/lib/router-utils/resolve-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ReturnType<typeof fsChecker.getItem>> = 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')) {
Expand Down
16 changes: 16 additions & 0 deletions test/e2e/i18n-app-pages-domain-base-path/app/[lang]/test/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const dynamic = 'force-dynamic'

export default async function TestPage({
params,
}: {
params: Promise<{ lang: string }>
}) {
const { lang } = await params

return (
<div>
<h1 id="test-page">App Router Test Page</h1>
<p id="test-locale">{lang}</p>
</div>
)
}
11 changes: 11 additions & 0 deletions test/e2e/i18n-app-pages-domain-base-path/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
Original file line number Diff line number Diff line change
@@ -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')
})
})
23 changes: 23 additions & 0 deletions test/e2e/i18n-app-pages-domain-base-path/next.config.js
Original file line number Diff line number Diff line change
@@ -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',
},
],
},
}
18 changes: 18 additions & 0 deletions test/e2e/i18n-app-pages-domain-base-path/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { GetServerSideProps } from 'next'

type Props = { locale: string }

export default function HomePage({ locale }: Props) {
return (
<div>
<h1 id="pages-home">Pages Router Home</h1>
<p id="pages-locale">{locale}</p>
</div>
)
}

export const getServerSideProps: GetServerSideProps<Props> = async (
context
) => {
return { props: { locale: context.locale || 'en-US' } }
}
48 changes: 48 additions & 0 deletions test/e2e/i18n-app-pages-domain-base-path/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server'

const LOCALE_DOMAINS: Record<string, string> = {
'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*'],
}
15 changes: 15 additions & 0 deletions test/e2e/i18n-app-pages-domain/app/[lang]/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export default async function BlogPostPage({
params,
}: {
params: Promise<{ lang: string; slug: string }>
}) {
const { lang, slug } = await params

return (
<div>
<h1 id="blog-page">App Router Blog Post</h1>
<p id="blog-locale">{lang}</p>
<p id="blog-slug">{slug}</p>
</div>
)
}
13 changes: 13 additions & 0 deletions test/e2e/i18n-app-pages-domain/app/[lang]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 19 additions & 0 deletions test/e2e/i18n-app-pages-domain/app/[lang]/test/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export default async function TestPage({
params,
}: {
params: Promise<{ lang: string }>
}) {
const { lang } = await params

return (
<div>
<h1 id="test-page">App Router Test Page</h1>
<p id="test-locale">{lang}</p>
<p id="test-message">
{lang === 'nl-NL'
? 'Dit is de Nederlandse versie'
: 'This is the English version'}
</p>
</div>
)
}
11 changes: 11 additions & 0 deletions test/e2e/i18n-app-pages-domain/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
Loading
Loading