Skip to content
Draft
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
19 changes: 19 additions & 0 deletions packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ import {
writeRouteTypesManifest,
writeValidatorFile,
writeRouteTypesEntryFile,
writeRootParamsTypesFile,
} from '../server/lib/router-utils/route-types-utils'
import { Lockfile } from './lockfile'
import {
Expand Down Expand Up @@ -1401,11 +1402,29 @@ export default async function build(
'types',
'routes.d.ts'
)
const isRootParamsEnabled = Boolean(
config.experimental.rootParams ?? config.cacheComponents
)

const actualTypesDir = path.join(distDir, 'types')
await writeRouteTypesEntryFile(entryFilePath, actualTypesDir, {
strictRouteTypes: Boolean(config.experimental.strictRouteTypes),
typedRoutes: Boolean(config.typedRoutes),
rootParams: isRootParamsEnabled,
})

// Generate root params types if experimental.rootParams (or cacheComponents) is enabled
if (isRootParamsEnabled) {
const rootParamsTypesFilePath = path.join(
distDir,
'types',
'root-params.d.ts'
)
writeRootParamsTypesFile(
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

Missing await before writeRootParamsTypesFile. Even though the function is currently synchronous, it should be called with await for consistency with line 1425 where writeCacheLifeTypesFile is awaited, and to prevent issues if the function becomes async in the future.

Suggested change
writeRootParamsTypesFile(
await writeRootParamsTypesFile(

Copilot uses AI. Check for mistakes.
routeTypesManifest,
rootParamsTypesFilePath
)
}
})

// Turbopack already handles conflicting app and page routes.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import fs from 'fs'
import path from 'path'

export type RootParamKind = 'dynamic' | 'catchall' | 'optional-catchall'

/**
* Generates TypeScript type definitions for the next/root-params virtual module.
* Creates typed getter functions for each root param found in the app's root layouts.
*/
export function generateRootParamsTypes(
rootParams: Map<string, RootParamKind>
): string {
const entries = Array.from(rootParams.entries()).sort(([a], [b]) =>
a.localeCompare(b)
)

const functions = entries.map(([name, kind]) => {
const returnType =
kind === 'dynamic'
? 'Promise<string>'
: kind === 'catchall'
? 'Promise<string[]>'
: 'Promise<string[] | undefined>'
return ` export function ${name}(): ${returnType}`
})

return `// Type definitions for Next.js root params

declare module 'next/root-params' {
${functions.join('\n')}
}
`
}

/**
* Writes root params type definitions to a file if rootParams exist.
* This is used by both the CLI (next type-gen) and dev server to generate
* root-params.d.ts in the types directory.
*/
export function writeRootParamsTypes(
rootParams: Map<string, RootParamKind> | undefined,
filePath: string
) {
if (!rootParams || rootParams.size === 0) {
return
}
Copy link
Contributor

Choose a reason for hiding this comment

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

When experimental.rootParams is enabled but no root layouts have dynamic segments, routes.d.ts imports root-params.d.ts which is never written, causing a TypeScript compilation error.

Fix on Vercel


const dirname = path.dirname(filePath)

if (!fs.existsSync(dirname)) {
fs.mkdirSync(dirname, { recursive: true })
}

const content = generateRootParamsTypes(rootParams)
fs.writeFileSync(filePath, content)
}
83 changes: 83 additions & 0 deletions packages/next/src/server/lib/router-utils/route-types-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import {
generateValidatorFileStrict,
generateRouteTypesFileStrict,
} from './typegen'
import {
writeRootParamsTypes,
type RootParamKind,
} from './root-params-type-utils'
import { getSegmentParam } from '../../../shared/lib/router/utils/get-segment-param'
import { tryToParsePath } from '../../../lib/try-to-parse-path'
import {
extractInterceptionRouteInformation,
Expand Down Expand Up @@ -48,6 +53,8 @@ export interface RouteTypesManifest {
pageApiRoutes: Set<string>
/** Direct mapping from file paths to routes for validation */
filePathToRoute: Map<string, string>
/** Root params collected from root layouts for generating root-params.d.ts */
rootParams?: Map<string, RootParamKind>
}

// Convert a custom-route source string (`/blog/:slug`, `/docs/:path*`, ...)
Expand Down Expand Up @@ -144,6 +151,68 @@ function resolveInterceptingRoute(route: string): string {
}
}

/**
* Identifies root layouts and collects their dynamic segments as root params.
* A root layout is the shallowest layout in each branch of the app directory.
*/
function collectRootParamsFromLayouts(
layoutRoutes: RouteInfo[]
): Map<string, RootParamKind> {
const rootParams = new Map<string, RootParamKind>()

// Sort by depth (fewer path segments = shallower = more likely to be root)
const sorted = [...layoutRoutes].sort(
(a, b) => a.route.split('/').length - b.route.split('/').length
)

const rootLayoutRoutes: string[] = []

for (const { route } of sorted) {
// Skip internal routes
if (
route === UNDERSCORE_GLOBAL_ERROR_ROUTE ||
route === UNDERSCORE_NOT_FOUND_ROUTE
) {
continue
}

// A layout is a root layout if no already-found root layout is an ancestor of it
const hasAncestorRootLayout = rootLayoutRoutes.some((rootRoute) =>
rootRoute === '/' ? route !== '/' : route.startsWith(rootRoute + '/')
)

if (hasAncestorRootLayout) continue

rootLayoutRoutes.push(route)

// Extract dynamic segments from this root layout's route
for (const segment of route.split('/')) {
const param = getSegmentParam(segment)
if (param === null) continue

const kind: RootParamKind =
param.paramType === 'optional-catchall'
? 'optional-catchall'
: param.paramType === 'catchall'
? 'catchall'
: 'dynamic'
Comment on lines +193 to +198
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

The current logic doesn't correctly handle intercepted route param types. When getSegmentParam returns param types like 'catchall-intercepted-(.)' or 'dynamic-intercepted-(...)', the direct equality checks will fail, causing intercepted catchall routes to be incorrectly classified as 'dynamic'.

Instead, import and use the existing getParamProperties helper from '../../../shared/lib/router/utils/get-segment-param':

Add to imports:

import { getSegmentParam, getParamProperties } from '../../../shared/lib/router/utils/get-segment-param'

Replace lines 197-202 with:

const { repeat, optional } = getParamProperties(param.paramType)
const kind: RootParamKind =
  repeat && optional
    ? 'optional-catchall'
    : repeat
      ? 'catchall'
      : 'dynamic'

This handles all param type variants including intercepted routes.

Copilot uses AI. Check for mistakes.

// If the same param name appears in multiple root layouts with different
// kinds, keep the most permissive type.
const existing = rootParams.get(param.paramName)
if (
!existing ||
kind === 'optional-catchall' ||
(kind === 'catchall' && existing === 'dynamic')
) {
rootParams.set(param.paramName, kind)
}
}
}

return rootParams
}

/**
* Creates a route types manifest from processed route data
* (used for both build and dev)
Expand Down Expand Up @@ -350,6 +419,8 @@ export async function createRouteTypesManifest({
}
}

manifest.rootParams = collectRootParamsFromLayouts(layoutRoutes)

return manifest
}

Expand Down Expand Up @@ -413,6 +484,7 @@ export async function writeRouteTypesEntryFile(
options: {
strictRouteTypes: boolean
typedRoutes: boolean
rootParams?: boolean
}
) {
const entryDir = path.dirname(entryFilePath)
Expand All @@ -432,6 +504,10 @@ export async function writeRouteTypesEntryFile(
`export type * from "${prefix}route-types.d.ts";`,
]

if (options.rootParams) {
lines.push(`import "${prefix}root-params.d.ts";`)
}

if (options.strictRouteTypes) {
lines.push(`import "${prefix}cache-life.d.ts";`)
lines.push(`import "${prefix}validator.ts";`)
Expand All @@ -445,3 +521,10 @@ export async function writeRouteTypesEntryFile(

await fs.promises.writeFile(entryFilePath, lines.join('\n'))
}

export function writeRootParamsTypesFile(
manifest: RouteTypesManifest,
filePath: string
) {
writeRootParamsTypes(manifest.rootParams, filePath)
}
Comment on lines +525 to +530
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

For consistency, this function should be declared as async to match the pattern used by writeCacheLifeTypesFile at line 529. Even though the underlying writeRootParamsTypes call is synchronous, using async maintains consistency with other file-writing functions in this module and allows for future async operations if needed.

Copilot uses AI. Check for mistakes.
14 changes: 14 additions & 0 deletions packages/next/src/server/lib/router-utils/setup-dev-bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import {
writeRouteTypesManifest,
writeValidatorFile,
writeRouteTypesEntryFile,
writeRootParamsTypesFile,
} from './route-types-utils'
import { writeCacheLifeTypes } from './cache-life-type-utils'
import {
Expand Down Expand Up @@ -1204,6 +1205,18 @@ async function startWatcher(
const cacheLifeFilePath = path.join(distTypesDir, 'cache-life.d.ts')
writeCacheLifeTypes(opts.nextConfig.cacheLife, cacheLifeFilePath)

// Generate root params types if experimental.rootParams (or cacheComponents) is enabled
const isRootParamsEnabled = Boolean(
nextConfig.experimental.rootParams ?? nextConfig.cacheComponents
)
if (isRootParamsEnabled) {
const rootParamsFilePath = path.join(
distTypesDir,
'root-params.d.ts'
)
writeRootParamsTypesFile(routeTypesManifest, rootParamsFilePath)
}

// Write the entry file at {distDirRoot}/types/routes.d.ts
// This ensures next-env.d.ts has a consistent import path
const entryFilePath = path.join(
Expand All @@ -1215,6 +1228,7 @@ async function startWatcher(
await writeRouteTypesEntryFile(entryFilePath, distTypesDir, {
strictRouteTypes: Boolean(nextConfig.experimental.strictRouteTypes),
typedRoutes: Boolean(nextConfig.typedRoutes),
rootParams: isRootParamsEnabled,
})
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// This file is type-checked by typecheck.test.ts after building the fixture.
// It verifies that the generated next/root-params types are correct.
import { lang, locale, path } from 'next/root-params'

// lang and locale are simple dynamic segments → Promise<string>
const langResult: Promise<string> = lang()
const localeResult: Promise<string> = locale()

// path appears in both catch-all and optional-catch-all layouts →
// most permissive type wins: Promise<string[] | undefined>
const pathResult: Promise<string[] | undefined> = path()

export { langResult, localeResult, pathResult }
81 changes: 81 additions & 0 deletions test/e2e/app-dir/app-root-params-getters/typecheck.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/* eslint-env jest */
import path from 'path'
import fs from 'fs-extra'
import { nextBuild } from 'next-test-utils'
import execa from 'execa'

const simpleFixtureDir = path.join(__dirname, 'fixtures', 'simple')
const multipleRootsFixtureDir = path.join(
__dirname,
'fixtures',
'multiple-roots'
)

// Turbopack doesn't use this type generation path (handled in Rust)
describe('root params type generation', () => {
describe('simple fixture', () => {
beforeAll(async () => {
await nextBuild(simpleFixtureDir, [], { stderr: true })
})

it('should generate root-params.d.ts with correct types', async () => {
const dts = (
await fs.readFile(
path.join(simpleFixtureDir, '.next', 'types', 'root-params.d.ts')
)
).toString()

// lang and locale are simple dynamic segments → Promise<string>
expect(dts).toContain(`export function lang(): Promise<string>`)
expect(dts).toContain(`export function locale(): Promise<string>`)

// path appears in catch-all and optional-catch-all → most permissive wins
expect(dts).toContain(
`export function path(): Promise<string[] | undefined>`
)
})

it('should include root-params.d.ts import in entry file', async () => {
const entryFile = (
await fs.readFile(
path.join(simpleFixtureDir, '.next', 'types', 'routes.d.ts')
)
).toString()

expect(entryFile).toContain(`import "./root-params.d.ts"`)
})

it('should type-check correctly', async () => {
const result = await execa('tsc', ['--noEmit'], {
cwd: simpleFixtureDir,
reject: false,
})
expect(result.stderr).not.toContain('error TS')
expect(result.stdout).not.toContain('error TS')
})
})

describe('multiple-roots fixture', () => {
beforeAll(async () => {
await nextBuild(multipleRootsFixtureDir, [], { stderr: true })
})

it('should generate root-params.d.ts with correct types', async () => {
const dts = (
await fs.readFile(
path.join(
multipleRootsFixtureDir,
'.next',
'types',
'root-params.d.ts'
)
)
).toString()

// Only the dashboard subtree has a dynamic segment
expect(dts).toContain(`export function id(): Promise<string>`)
// landing layout has no params
expect(dts).not.toContain('lang')
})
})
})
Loading