cli: build partial entries --debug-build-paths arg (#85052)

Add `--debug-build-paths` CLI option to next build for selective route
building. This enables building only specific routes for faster
development and debugging.

### Changes

 - Add `--debug-build-paths=<patterns>` CLI option
 - Support comma-separated paths and glob patterns
 - Filter collected routes after normal file system scanning
 - Works with both app router and pages router

### Usage

#### Build specific routes
```sh
 next build --debug-build-paths="app/page.tsx,app/dashboard/page.tsx"
 next build --debug-build-paths="pages/index.tsx,pages/about.tsx"
```
#### Use glob patterns

```sh
next build --debug-build-paths "app/**/page.tsx"
next build --debug-build-paths "pages/*.tsx"
```

#### Mix both routers
```
next build --debug-build-paths "app/page.tsx,pages/index.tsx"
```
This commit is contained in:
Jiachi Liu
2025-10-19 14:18:03 -07:00
committed by GitHub
parent 09772c3aa8
commit 700fecf2f2
14 changed files with 340 additions and 5 deletions
+3 -1
View File
@@ -888,5 +888,7 @@
"887": "`cacheLife()` is only available with the `cacheComponents` config.",
"888": "Unknown \\`cacheLife()\\` profile \"%s\" is not configured in next.config.js\\nmodule.exports = {\n cacheLife: {\n \"%s\": ...\\n }\n}",
"889": "Unknown \\`cacheLife()\\` profile \"%s\" is not configured in next.config.js\\nmodule.exports = {\n cacheLife: {\n \"%s\": ...\\n }\n}",
"890": "Received an underlying cookies object that does not match either `cookies` or `mutableCookies`"
"890": "Received an underlying cookies object that does not match either `cookies` or `mutableCookies`",
"891": "Failed to read build paths file \"%s\": %s",
"892": "Failed to resolve glob pattern \"%s\": %s"
}
+4
View File
@@ -154,6 +154,10 @@ program
'--experimental-next-config-strip-types',
'Use Node.js native TypeScript resolution for next.config.(ts|mts)'
)
.option(
'--debug-build-paths <patterns>',
'Comma-separated glob patterns or explicit paths for selective builds. Examples: "app/*", "app/page.tsx", "app/**/page.tsx"'
)
.action((directory: string, options: NextBuildOptions) => {
if (options.experimentalNextConfigStripTypes) {
process.env.__NEXT_NODE_NATIVE_TS_LOADER_ENABLED = 'true'
+32 -2
View File
@@ -910,7 +910,9 @@ export default async function build(
appDirOnly = false,
bundler = Bundler.Turbopack,
experimentalBuildMode: 'default' | 'compile' | 'generate' | 'generate-env',
traceUploadUrl: string | undefined
traceUploadUrl: string | undefined,
debugBuildAppPaths?: string[],
debugBuildPagePaths?: string[]
): Promise<void> {
const isCompileMode = experimentalBuildMode === 'compile'
const isGenerateMode = experimentalBuildMode === 'generate'
@@ -1166,6 +1168,20 @@ export default async function build(
.traceAsyncFn(() => collectPagesFiles(pagesDir, validFileMatcher))
: []
// Apply debug build paths filter if specified
// If debugBuildPagePaths is defined (even if empty), only build specified pages
if (debugBuildPagePaths !== undefined) {
if (debugBuildPagePaths.length > 0) {
const debugPathsSet = new Set(debugBuildPagePaths)
pagesPaths = pagesPaths.filter((pagePath) =>
debugPathsSet.has(pagePath)
)
} else {
// Empty array means build no pages
pagesPaths = []
}
}
const middlewareDetectionRegExp = new RegExp(
`^${MIDDLEWARE_FILENAME}\\.(?:${config.pageExtensions.join('|')})$`
)
@@ -1275,7 +1291,7 @@ export default async function build(
let layoutPaths: string[]
if (Boolean(process.env.NEXT_PRIVATE_APP_PATHS)) {
// used for testing?
// used for testing
appPaths = providedAppPaths
layoutPaths = []
} else {
@@ -1286,6 +1302,20 @@ export default async function build(
appPaths = result.appPaths
layoutPaths = result.layoutPaths
// Apply debug build paths filter if specified
// If debugBuildAppPaths is defined (even if empty), only build specified app paths
if (debugBuildAppPaths !== undefined) {
if (debugBuildAppPaths.length > 0) {
const debugPathsSet = new Set(debugBuildAppPaths)
appPaths = appPaths.filter((appPath) =>
debugPathsSet.has(appPath)
)
} else {
// Empty array means build no app paths
appPaths = []
}
}
// Note: defaultPaths are not used in the build process, only for slot detection in generating route types
}
+31 -2
View File
@@ -11,6 +11,10 @@ import { getProjectDir } from '../lib/get-project-dir'
import { enableMemoryDebuggingMode } from '../lib/memory/startup'
import { disableMemoryDebuggingMode } from '../lib/memory/shutdown'
import { parseBundlerArgs } from '../lib/bundler'
import {
resolveBuildPaths,
parseBuildPathsInput,
} from '../lib/resolve-build-paths'
export type NextBuildOptions = {
debug?: boolean
@@ -26,9 +30,10 @@ export type NextBuildOptions = {
experimentalBuildMode: 'default' | 'compile' | 'generate' | 'generate-env'
experimentalUploadTrace?: string
experimentalNextConfigStripTypes?: boolean
debugBuildPaths?: string
}
const nextBuild = (options: NextBuildOptions, directory?: string) => {
const nextBuild = async (options: NextBuildOptions, directory?: string) => {
process.on('SIGTERM', () => process.exit(143))
process.on('SIGINT', () => process.exit(130))
@@ -41,6 +46,7 @@ const nextBuild = (options: NextBuildOptions, directory?: string) => {
experimentalAppOnly,
experimentalBuildMode,
experimentalUploadTrace,
debugBuildPaths,
} = options
let traceUploadUrl: string | undefined
@@ -81,6 +87,27 @@ const nextBuild = (options: NextBuildOptions, directory?: string) => {
const bundler = parseBundlerArgs(options)
// Resolve selective build paths
let resolvedAppPaths: string[] | undefined
let resolvedPagePaths: string[] | undefined
if (debugBuildPaths) {
try {
const patterns = parseBuildPathsInput(debugBuildPaths)
if (patterns.length > 0) {
const resolved = await resolveBuildPaths(patterns, dir)
// Pass empty arrays to indicate "build nothing" vs undefined for "build everything"
resolvedAppPaths = resolved.appPaths
resolvedPagePaths = resolved.pagePaths
}
} catch (err) {
printAndExit(
`Failed to resolve build paths: ${isError(err) ? err.message : String(err)}`
)
}
}
return build(
dir,
profile,
@@ -90,7 +117,9 @@ const nextBuild = (options: NextBuildOptions, directory?: string) => {
experimentalAppOnly,
bundler,
experimentalBuildMode,
traceUploadUrl
traceUploadUrl,
resolvedAppPaths,
resolvedPagePaths
)
.catch((err) => {
if (experimentalDebugMemoryUsage) {
@@ -0,0 +1,156 @@
import { promisify } from 'util'
import globOriginal from 'next/dist/compiled/glob'
import * as Log from '../build/output/log'
import path from 'path'
import fs from 'fs'
import isError from './is-error'
const glob = promisify(globOriginal)
interface ResolvedBuildPaths {
appPaths: string[]
pagePaths: string[]
}
/**
* Resolves glob patterns and explicit paths to actual file paths
* Categorizes them into App Router and Pages Router paths
*
* @param patterns - Array of glob patterns or explicit paths
* @param projectDir - Root project directory
* @returns Object with categorized app and page paths
*/
export async function resolveBuildPaths(
patterns: string[],
projectDir: string
): Promise<ResolvedBuildPaths> {
const appPaths: Set<string> = new Set()
const pagePaths: Set<string> = new Set()
for (const pattern of patterns) {
const trimmed = pattern.trim()
if (!trimmed) {
continue
}
// Detect if pattern is glob pattern (contains glob special chars)
const isGlobPattern = /[*?[\]{}!]/.test(trimmed)
if (isGlobPattern) {
try {
// Resolve glob pattern
const matches = (await glob(trimmed, {
cwd: projectDir,
})) as string[]
if (matches.length === 0) {
Log.warn(`Glob pattern "${trimmed}" did not match any files`)
}
for (const file of matches) {
// Skip directories, only process files
if (!fs.statSync(path.join(projectDir, file)).isDirectory()) {
categorizeAndAddPath(file, appPaths, pagePaths)
}
}
} catch (error) {
throw new Error(
`Failed to resolve glob pattern "${trimmed}": ${
isError(error) ? error.message : String(error)
}`
)
}
} else {
// Explicit path - categorize based on prefix
categorizeAndAddPath(trimmed, appPaths, pagePaths, projectDir)
}
}
return {
appPaths: Array.from(appPaths).sort(),
pagePaths: Array.from(pagePaths).sort(),
}
}
/**
* Categorizes a file path to either app or pages router based on its prefix,
* and normalizes it to the format expected by Next.js internal build system.
*
* The internal build system expects:
* - App router: paths with leading slash (e.g., "/page.tsx", "/dashboard/page.tsx")
* - Pages router: paths with leading slash (e.g., "/index.tsx", "/about.tsx")
*
* Examples:
* - "app/page.tsx" → appPaths.add("/page.tsx")
* - "app/dashboard/page.tsx" → appPaths.add("/dashboard/page.tsx")
* - "pages/index.tsx" → pagePaths.add("/index.tsx")
* - "pages/about.tsx" → pagePaths.add("/about.tsx")
* - "/page.tsx" → appPaths.add("/page.tsx") (already in app router format)
*/
function categorizeAndAddPath(
filePath: string,
appPaths: Set<string>,
pagePaths: Set<string>,
projectDir?: string
): void {
// Normalize path separators to forward slashes (Windows compatibility)
const normalized = filePath.replace(/\\/g, '/')
// Skip non-file entries (like directories without extensions)
if (normalized.endsWith('/')) {
return
}
if (normalized.startsWith('app/')) {
// App router path: remove 'app/' prefix and ensure leading slash
// "app/page.tsx" → "/page.tsx"
// "app/dashboard/page.tsx" → "/dashboard/page.tsx"
const withoutPrefix = normalized.slice(4) // Remove "app/"
appPaths.add('/' + withoutPrefix)
} else if (normalized.startsWith('pages/')) {
// Pages router path: remove 'pages/' prefix and add leading slash
// "pages/index.tsx" → "/index.tsx"
// "pages/about.tsx" → "/about.tsx"
const withoutPrefix = normalized.slice(6) // Remove "pages/"
pagePaths.add('/' + withoutPrefix)
} else if (normalized.startsWith('/')) {
// Leading slash suggests app router format (already in correct format)
// "/page.tsx" → "/page.tsx" (no change needed)
appPaths.add(normalized)
} else {
// No obvious prefix - try to detect based on file existence
if (projectDir) {
const appPath = path.join(projectDir, 'app', normalized)
const pagesPath = path.join(projectDir, 'pages', normalized)
if (fs.existsSync(appPath)) {
appPaths.add('/' + normalized)
} else if (fs.existsSync(pagesPath)) {
pagePaths.add('/' + normalized)
} else {
// Default to pages router for paths without clear indicator
pagePaths.add('/' + normalized)
}
} else {
// Without projectDir context, default to pages router
pagePaths.add('/' + normalized)
}
}
}
/**
* Parse build paths from comma-separated format
* Supports:
* - Comma-separated values: "app/page.tsx,app/about/page.tsx"
*
* @param input - String input to parse
* @returns Array of path patterns
*/
export function parseBuildPathsInput(input: string): string[] {
// Comma-separated values
return input
.split(',')
.map((p) => p.trim())
.filter((p) => p.length > 0)
}
@@ -0,0 +1,3 @@
export default function About() {
return <h1>App About</h1>
}
@@ -0,0 +1,3 @@
export default function Dashboard() {
return <h1>App Dashboard</h1>
}
@@ -0,0 +1,17 @@
import React from 'react'
export const metadata = {
title: 'Debug Build Paths Test',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,3 @@
export default function Home() {
return <h1>App Home</h1>
}
@@ -0,0 +1,66 @@
import { nextTestSetup } from 'e2e-utils'
describe('debug-build-paths', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
skipDeployment: true,
skipStart: true,
})
if (skipped) return
describe('explicit path formats', () => {
it('should build single page with pages/ prefix', async () => {
const buildResult = await next.build({
args: ['--debug-build-paths', 'pages/foo.tsx'],
})
expect(buildResult.exitCode).toBe(0)
expect(buildResult.cliOutput).toBeDefined()
// Should only build the specified page
expect(buildResult.cliOutput).toContain('Route (pages)')
expect(buildResult.cliOutput).toContain('○ /foo')
// Should not build other pages
expect(buildResult.cliOutput).not.toContain('○ /bar')
// Should not build app routes
expect(buildResult.cliOutput).not.toContain('Route (app)')
})
it('should build multiple pages routes', async () => {
const buildResult = await next.build({
args: ['--debug-build-paths', 'pages/foo.tsx,pages/bar.tsx'],
})
expect(buildResult.exitCode).toBe(0)
expect(buildResult.cliOutput).toBeDefined()
// Should build both specified pages
expect(buildResult.cliOutput).toContain('Route (pages)')
expect(buildResult.cliOutput).toContain('○ /foo')
expect(buildResult.cliOutput).toContain('○ /bar')
// Should not build app routes
expect(buildResult.cliOutput).not.toContain('Route (app)')
})
})
describe('glob pattern matching', () => {
it('should match app and pages routes with glob patterns', async () => {
const buildResult = await next.build({
args: ['--debug-build-paths', 'pages/*.tsx,app/page.tsx'],
})
expect(buildResult.exitCode).toBe(0)
expect(buildResult.cliOutput).toBeDefined()
// Should build pages matching the glob
expect(buildResult.cliOutput).toContain('Route (pages)')
expect(buildResult.cliOutput).toContain('○ /foo')
expect(buildResult.cliOutput).toContain('○ /bar')
// Should build the specified app route
expect(buildResult.cliOutput).toContain('Route (app)')
expect(buildResult.cliOutput).toContain('○ /')
// Should not build other app routes
expect(buildResult.cliOutput).not.toContain('○ /about')
expect(buildResult.cliOutput).not.toContain('○ /dashboard')
})
})
})
@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig
@@ -0,0 +1,12 @@
import type { NextApiRequest, NextApiResponse } from 'next'
type ResponseData = {
message: string
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
res.status(200).json({ message: 'Hello from API' })
}
@@ -0,0 +1,3 @@
export default function Page() {
return <h1>Bar</h1>
}
@@ -0,0 +1,3 @@
export default function Page() {
return <h1>Foo</h1>
}