Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ If you need to use Client Component hooks like `usePathname` to display content

### Metadata

For `global-not-found.js`, you can export a `metadata` object or a [`generateMetadata`](/docs/app/api-reference/functions/generate-metadata) function to customize the `<title>`, `<meta>`, and other head tags for your 404 page:
Both the root `app/not-found.js` and `app/global-not-found.js` support exporting a `metadata` object or a [`generateMetadata`](/docs/app/api-reference/functions/generate-metadata) function to customize the `<title>`, `<meta>`, and other head tags for your 404 page:

> **Good to know**: Next.js automatically injects `<meta name="robots" content="noindex" />` for pages that return a 404 status code, including `global-not-found.js` pages.

Expand Down
2 changes: 2 additions & 0 deletions docs/01-app/04-glossary.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ nav_title: Glossary
description: A glossary of common terms used in Next.js.
---

This glossary defines common terms used throughout the Next.js documentation, including terms related to routing, rendering, and caching.

# A

## App Router
Expand Down
54 changes: 39 additions & 15 deletions packages/next-routing/src/destination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,54 @@ export function replaceDestination(
regexMatches: RegExpMatchArray | null,
hasCaptures: Record<string, string>
): string {
let result = destination
const captureKeys = Object.keys(hasCaptures)

// Replace numbered captures from regex ($1, $2, etc.)
if (regexMatches) {
// Replace numbered groups (skip index 0 which is the full match)
for (let i = 1; i < regexMatches.length; i++) {
const value = regexMatches[i] ?? ''
result = result.replace(new RegExp(`\\$${i}`, 'g'), value)
for (let index = 1; index < regexMatches.length; index++) {
captureKeys.push(String(index))
}

// Replace named groups ($name)
if (regexMatches.groups) {
for (const [name, value] of Object.entries(regexMatches.groups)) {
result = result.replace(new RegExp(`\\$${name}`, 'g'), value ?? '')
}
captureKeys.push(...Object.keys(regexMatches.groups))
}
}

// Replace named captures from has conditions
for (const [name, value] of Object.entries(hasCaptures)) {
result = result.replace(new RegExp(`\\$${name}`, 'g'), value)
if (captureKeys.length === 0) {
return destination
}

return result
const capturePattern = captureKeys
.sort((first, second) => second.length - first.length)
.map((key) => {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return /^\d+$/.test(key) ? `${escapedKey}(?!\\d)` : escapedKey
})
.join('|')

// Replace placeholders once. Captured paths can contain literal text such as
// $2 or $d$segment that must not become another substitution.
return destination.replace(
new RegExp(`\\$(${capturePattern})`, 'g'),
(placeholder, key: string) => {
if (regexMatches) {
const index = Number(key)
if (
Number.isInteger(index) &&
index > 0 &&
index < regexMatches.length &&
String(index) === key
) {
return regexMatches[index] ?? ''
}
if (regexMatches.groups && Object.hasOwn(regexMatches.groups, key)) {
return regexMatches.groups[key] ?? ''
}
}
if (Object.hasOwn(hasCaptures, key)) {
return hasCaptures[key]
}
return placeholder
}
)
}

/**
Expand Down
51 changes: 39 additions & 12 deletions packages/next/src/build/adapter/build-complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2110,6 +2110,10 @@ export async function handleBuildComplete({
(page) => prerenderManifest.dynamicRoutes[page]?.fallback === false
)
: undefined
const escapedBasePath =
config.basePath && config.basePath !== '/'
? escapeStringRegexp(path.posix.join('/', config.basePath))
: ''

for (const route of routesManifest.dynamicRoutes) {
// An earlier entry in this loop serves this shell.
Expand All @@ -2133,6 +2137,10 @@ export async function handleBuildComplete({
// An entry for a whole run of shells matches every prefix in that run.
// The destination copies the prefix that matched.
//
// The prefix and RSC suffix use unnamed captures. Adapters can forward
// named captures to the application query when they bypass prerendered
// output.
//
// This replacement runs on the pattern for the page, and `sourceRegex`
// below prefixes the result with the base path and the locale group. That
// order is deliberate. The search text anchors at `^`, and here that
Expand All @@ -2143,18 +2151,24 @@ export async function handleBuildComplete({
const pagePattern = fallbackShellRun
? routeRegex.namedRegex.replace(
`^/${escapeStringRegexp(fallbackShellRun.prefixes[0])}/`,
`^/(?<shellPrefix>${fallbackShellRun.prefixes
.map((prefix) => escapeStringRegexp(prefix))
.join('|')})/`
() =>
`^/(${fallbackShellRun.prefixes
.map((prefix) => escapeStringRegexp(prefix))
.join('|')})/`
)
: routeRegex.namedRegex
const pagePath = fallbackShellRun
? path.posix.join('/', '$shellPrefix', fallbackShellRun.tail)
? path.posix.join(
'/',
shouldLocalize ? '$2' : '$1',
fallbackShellRun.tail
)
: route.page

const sourceRegex = pagePattern.replace(
'^',
`^${config.basePath && config.basePath !== '/' ? path.posix.join('/', config.basePath || '') : ''}[/]?${shouldLocalize ? '(?<nextLocale>[^/]{1,})' : ''}`
() =>
`^${escapedBasePath}[/]?${shouldLocalize ? '(?<nextLocale>[^/]{1,})' : ''}`
)
const destination =
path.posix.join(
Expand All @@ -2163,6 +2177,13 @@ export async function handleBuildComplete({
shouldLocalize ? '/$nextLocale' : '',
pagePath
) + getDestinationQuery(route.routeKeys)
// Count capture names, not parameter names. An interception route can
// capture the same parameter with both nxtP and nxtI names.
const suffixCaptureIndex =
Object.keys(routeRegex.routeKeys).length +
(shouldLocalize ? 1 : 0) +
(fallbackShellRun ? 1 : 0) +
1

const hasAppPages = Boolean(appPageKeys && appPageKeys.length > 0)

Expand Down Expand Up @@ -2196,15 +2217,18 @@ export async function handleBuildComplete({
// An optional group is unsafe here. An adapter, or the router that
// consumes its output, can resolve the placeholders in a destination
// from the match result rather than from the pattern. A group that does
// not match is then absent from that result, and the literal text
// `$rscSuffix` stays in the destination.
// not match is then absent from that result, and the destination
// placeholder stays unresolved.
dynamicRoutes.push({
source: pagePath,
sourceRegex: sourceRegex.replace(
new RegExp(escapeStringRegexp('(?:/)?$')),
'(?<rscSuffix>\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$'
'(\\.rsc|\\.segments/.+\\.segment\\.rsc|)(?:/)?$'
),
destination: destination.replace(
/($|\?)/,
(separator) => `$${suffixCaptureIndex}${separator}`
),
destination: destination?.replace(/($|\?)/, '$rscSuffix$1'),
has: plainHas,
missing: undefined,
})
Expand All @@ -2218,9 +2242,12 @@ export async function handleBuildComplete({
source: pagePath + '.rsc',
sourceRegex: sourceRegex.replace(
new RegExp(escapeStringRegexp('(?:/)?$')),
'(?<rscSuffix>\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$'
'(\\.rsc|\\.segments/.+\\.segment\\.rsc)(?:/)?$'
),
destination: destination.replace(
/($|\?)/,
(separator) => `$${suffixCaptureIndex}${separator}`
),
destination: destination?.replace(/($|\?)/, '$rscSuffix$1'),
has: suffixedHas,
missing: undefined,
})
Expand All @@ -2246,7 +2273,7 @@ export async function handleBuildComplete({
source: route.page,
sourceRegex: segmentRoute.source.replace(
'^',
`^${config.basePath && config.basePath !== '/' ? path.posix.join('/', config.basePath || '') : ''}[/]?`
() => `^${escapedBasePath}[/]?`
),
destination: path.posix.join(
'/',
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/build/adapter/fallback-shell-runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ function splitShellPage(
* with shells for `acme/en`, `acme/de` and `globex/en`, that pattern holds:
*
* ```
* (?<shellPrefix>acme/en|acme/de|globex/en)
* (acme/en|acme/de|globex/en)
* ```
*
* A pattern that offered a choice per param instead, such as
Expand Down
78 changes: 38 additions & 40 deletions packages/next/src/build/templates/app-page-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,15 @@ export function createAppPageEntrypoint({

const remainingPrerenderableParams =
prerenderInfo?.remainingPrerenderableParams ?? []
const remainingFallbackRouteParams = nextConfig.cacheComponents
? (prerenderInfo?.fallbackRouteParams?.filter(
(param) =>
!remainingPrerenderableParams.some(
(prerenderableParam) =>
prerenderableParam.paramName === param.paramName
)
) ?? [])
: []
// Concrete optional routes like `/optional-catchall` can still match their
// generic shell entry (eg /optional-catchall/[[...slug]]) in the prerender manifest.
// If the omitted param already resolved to a real prerendered path, keep serving that concrete result.
Expand All @@ -586,11 +595,11 @@ export function createAppPageEntrypoint({
prerenderInfo?.fallback === null &&
(prerenderInfo.fallbackRootParams?.length ?? 0) > 0

// SSG writes and navigation RDC reads use the same completed-shell key.
// Completion uses the matched shell rather than the fully resolved
// pathname. A request for `/prefix/c/foo` can complete
// `/prefix/[one]/[two]` to `/prefix/c/[two]`. This avoids creating an entry
// for every value of `two`.
// SSG writes and navigation RDC reads use the same shell key. Completion
// uses the matched shell rather than the fully resolved pathname. A request
// for `/prefix/c/foo` can complete `/prefix/[one]/[two]` to
// `/prefix/c/[two]`. This avoids creating an entry for every value of
// `two`.
//
// The completed-shell key also applies when unresolved root params require
// a blocking render. A source shell cannot be shared across root branches.
Expand All @@ -603,27 +612,33 @@ export function createAppPageEntrypoint({
? prerenderInfo.fallback
: prerenderMatch.source
: null
let completedShellCacheKey: string | null = null
let shellCacheKey: string | null = null
if (
nextConfig.partialPrefetching &&
nextConfig.cacheComponents &&
// Never-prerenderable params must stay out of the key even when Partial
// Prefetching is disabled.
(nextConfig.partialPrefetching ||
remainingFallbackRouteParams.length > 0) &&
fallbackPathname &&
prerenderInfo?.fallbackRouteParams?.length &&
remainingPrerenderableParams.length > 0
!hasOmittedConcreteFallbackParam
) {
const cacheKey = buildCompletedShellCacheKey(
fallbackPathname,
remainingPrerenderableParams,
params
)

// Only a more complete shell gets a separate cache entry.
if (cacheKey !== fallbackPathname) {
completedShellCacheKey = cacheKey
if (
cacheKey !== fallbackPathname ||
remainingFallbackRouteParams.length > 0
) {
shellCacheKey = cacheKey
}
}

let ssgCacheKey: string | null = null
let usesCompletedShellCacheKey = false
let usesShellCacheKey = false
if (
!isDraftMode &&
isSSG &&
Expand All @@ -632,19 +647,14 @@ export function createAppPageEntrypoint({
!hasPostponedState &&
!isDynamicRSCRequest
) {
if (
// Partial fallback shells are only specialized per request when Partial
// Prefetching is enabled, mirroring the `partialFallback` flag the
// adapter emits for deployments. When it's disabled we fall through to
// the normal ISR cache key (`resolvedPathname`) so the shell stays
// shared, matching the behavior before the `partialFallbacks` config
// flag was removed.
nextConfig.partialPrefetching &&
fallbackPathname &&
prerenderInfo?.fallbackRouteParams?.length
) {
ssgCacheKey = completedShellCacheKey
usesCompletedShellCacheKey = completedShellCacheKey !== null
if (shellCacheKey !== null) {
// Normal fallback serving uses the source shell's separate cache
// lookup. Explicit revalidation skips that path, so it must write the
// source key here even when no params can be completed.
if (shellCacheKey !== fallbackPathname || isOnDemandRevalidate) {
ssgCacheKey = shellCacheKey
usesShellCacheKey = true
}
} else {
ssgCacheKey = resolvedPathname
}
Expand Down Expand Up @@ -709,17 +719,6 @@ export function createAppPageEntrypoint({
const isWrappedByNextServer = Boolean(
routerServerContext?.isWrappedByNextServer
)
const remainingFallbackRouteParams =
nextConfig.partialPrefetching && remainingPrerenderableParams.length > 0
? (prerenderInfo?.fallbackRouteParams?.filter(
(param) =>
!remainingPrerenderableParams.some(
(prerenderableParam) =>
prerenderableParam.paramName === param.paramName
)
) ?? [])
: []

const render404 = async () => {
// TODO: should route-module itself handle rendering the 404
if (routerServerContext?.render404) {
Expand Down Expand Up @@ -1371,8 +1370,7 @@ export function createAppPageEntrypoint({
// from entering an infinite loop of revalidations.
!forceStaticRender
) {
const incrementalCacheKey =
completedShellCacheKey ?? resolvedPathname
const incrementalCacheKey = shellCacheKey ?? resolvedPathname
const incrementalCacheEntry = await incrementalCache.get(
incrementalCacheKey,
{
Expand Down Expand Up @@ -1539,8 +1537,8 @@ export function createAppPageEntrypoint({
// The platform strips never-prerenderable param values before
// calling the origin.
!isMinimalMode &&
(usesCompletedShellCacheKey ||
(forceStaticRender && completedShellCacheKey !== null)) &&
(usesShellCacheKey ||
(forceStaticRender && shellCacheKey !== null)) &&
remainingFallbackRouteParams.length > 0
? createOpaqueFallbackRouteParams(
remainingFallbackRouteParams
Expand Down
5 changes: 0 additions & 5 deletions packages/next/src/server/request/fallback-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,6 @@ export function createOpaqueFallbackRouteParams(
* exactly that shape. Any other shell may be empty, and a request completes it
* with the params that `generateStaticParams` can still supply. Only the params
* that completion never resolves stay deferred.
*
* `next start` without `partialPrefetching` keys ISR entries by the full
* pathname, so such an entry resolves every param. A cold staged render then
* defers params that the entry resolves, so its static stage contains less
* content. A resume reads the recorded set from the entry's postponed state.
*/
export function getStagedFallbackParams(route: {
fallbackRouteParams: readonly FallbackRouteParam[] | undefined
Expand Down
Loading
Loading