Repository: vercel/next.js
Pull request: feat: next typegen command

## Add `next typegen` CLI command for fast type generation

Adds a new `next typegen` command that generates TypeScript definitions for routes, pages, and layouts without running a full build, enabling faster type checking in CI and development workflows.

**New command:**
```bash
next typegen [directory]
```

**Implementation:**
- Extracts type generation logic from `packages/next/src/build/index.ts` into standalone CLI command
- Reuses existing route manifest creation and file writing utilities
- Maintains all functionality from build-time type generation
- Proper CLI integration with help text and error handling

Changed files:
filename: packages/next/src/bin/next.ts
patch: @@ -20,6 +20,7 @@ import type { NextLintOptions } from '../cli/next-lint.js'
 import type { NextInfoOptions } from '../cli/next-info.js'
 import type { NextDevOptions } from '../cli/next-dev.js'
 import type { NextBuildOptions } from '../cli/next-build.js'
+import type { NextTypegenOptions } from '../cli/next-typegen.js'
 
 if (process.env.NEXT_RSPACK) {
   // silent rspack's schema check
@@ -382,6 +383,24 @@ program
     )
   )
 
+program
+  .command('typegen')
+  .description(
+    'Generate TypeScript definitions for routes, pages, and layouts without running a full build.'
+  )
+  .argument(
+    '[directory]',
+    `A directory on which to generate types. ${italic(
+      'If no directory is provided, the current directory will be used.'
+    )}`
+  )
+  .action((directory: string, options: NextTypegenOptions) =>
+    import('../cli/next-typegen.js').then((mod) =>
+      mod.nextTypegen(options, directory)
+    )
+  )
+  .usage('[directory] [options]')
+
 program
   .command('experimental-test')
   .description(

filename: packages/next/src/build/entries.ts
patch: @@ -75,6 +75,224 @@ import type { PageExtensions } from './page-extensions-type'
 import type { MappedPages } from './build-context'
 import { PAGE_TYPES } from '../lib/page-types'
 import { isAppPageRoute } from '../lib/is-app-page-route'
+import { recursiveReadDir } from '../lib/recursive-readdir'
+import { createValidFileMatcher } from '../server/lib/find-page-file'
+import { isReservedPage } from './utils'
+import { isParallelRouteSegment } from '../shared/lib/segment'
+import { ensureLeadingSlash } from '../shared/lib/page-path/ensure-leading-slash'
+
+/**
+ * Collect app pages and layouts from the app directory
+ * @param appDir - The app directory path
+ * @param pageExtensions - The configured page extensions
+ * @param options - Optional configuration
+ * @returns Object containing appPaths and layoutPaths arrays
+ */
+export async function collectAppFiles(
+  appDir: string,
+  pageExtensions: PageExtensions
+): Promise<{
+  appPaths: string[]
+  layoutPaths: string[]
+}> {
+  const validFileMatcher = createValidFileMatcher(pageExtensions, appDir)
+
+  // Collect both app pages and layouts in a single directory traversal
+  const allAppFiles = await recursiveReadDir(appDir, {
+    pathnameFilter: (absolutePath) =>
+      validFileMatcher.isAppRouterPage(absolutePath) ||
+      validFileMatcher.isRootNotFound(absolutePath) ||
+      validFileMatcher.isAppLayoutPage(absolutePath),
+    ignorePartFilter: (part) => part.startsWith('_'),
+  })
+
+  // Separate app pages from layouts
+  const appPaths = allAppFiles.filter(
+    (absolutePath) =>
+      validFileMatcher.isAppRouterPage(absolutePath) ||
+      validFileMatcher.isRootNotFound(absolutePath)
+  )
+  const layoutPaths = allAppFiles.filter((absolutePath) =>
+    validFileMatcher.isAppLayoutPage(absolutePath)
+  )
+
+  return { appPaths, layoutPaths }
+}
+
+/**
+ * Collect pages from the pages directory
+ * @param pagesDir - The pages directory path
+ * @param pageExtensions - The configured page extensions
+ * @returns Array of page file paths
+ */
+export async function collectPagesFiles(
+  pagesDir: string,
+  pageExtensions: PageExtensions
+): Promise<string[]> {
+  return recursiveReadDir(pagesDir, {
+    pathnameFilter: (absolutePath) => {
+      const relativePath = absolutePath.replace(pagesDir + '/', '')
+      return pageExtensions.some((ext) => relativePath.endsWith(`.${ext}`))
+    },
+    ignorePartFilter: (part) => part.startsWith('_'),
+  })
+}
+
+// Types for route processing
+export type RouteInfo = {
+  route: string
+  filePath: string
+}
+
+export type SlotInfo = {
+  name: string
+  parent: string
+}
+
+/**
+ * Create a relative file path from a mapped page path
+ * @param baseDir - The base directory path
+ * @param filePath - The mapped file path (with private prefix)
+ * @param prefix - The directory prefix ('pages' or 'app')
+ * @returns The relative file path
+ */
+export function createRelativeFilePath(
+  baseDir: string,
+  filePath: string,
+  prefix: 'pages' | 'app'
+): string {
+  const privatePrefix =
+    prefix === 'pages' ? 'private-next-pages' : 'private-next-app-dir'
+  return join(
+    baseDir,
+    filePath.replace(new RegExp(`^${privatePrefix}/`), `${prefix}/`)
+  )
+}
+
+/**
+ * Process pages routes from mapped pages
+ * @param mappedPages - The mapped pages object
+ * @param baseDir - The base directory path
+ * @returns Object containing pageRoutes and pageApiRoutes
+ */
+export function processPageRoutes(
+  mappedPages: { [page: string]: string },
+  baseDir: string
+): {
+  pageRoutes: RouteInfo[]
+  pageApiRoutes: RouteInfo[]
+} {
+  const pageRoutes: RouteInfo[] = []
+  const pageApiRoutes: RouteInfo[] = []
+
+  for (const [route, filePath] of Object.entries(mappedPages)) {
+    const relativeFilePath = createRelativeFilePath(baseDir, filePath, 'pages')
+
+    if (route.startsWith('/api/')) {
+      pageApiRoutes.push({
+        route: normalizePathSep(route),
+        filePath: relativeFilePath,
+      })
+    } else {
+      // Filter out _app, _error, _document
+      if (isReservedPage(route)) continue
+
+      pageRoutes.push({
+        route: normalizePathSep(route),
+        filePath: relativeFilePath,
+      })
+    }
+  }
+
+  return { pageRoutes, pageApiRoutes }
+}
+
+/**
+ * Extract slots from app routes
+ * @param mappedAppPages - The mapped app pages object
+ * @returns Array of slot information
+ */
+export function extractSlotsFromAppRoutes(mappedAppPages: {
+  [page: string]: string
+}): SlotInfo[] {
+  const slots: SlotInfo[] = []
+
+  for (const [route] of Object.entries(mappedAppPages)) {
+    if (route === '/_not-found/page') continue
+
+    const segments = route.split('/')
+    for (let i = segments.length - 1; i >= 0; i--) {
+      const segment = segments[i]
+      if (isParallelRouteSegment(segment)) {
+        const parentPath = normalizeAppPath(segments.slice(0, i).join('/'))
+        const slotName = segment.slice(1)
+
+        // Check if the slot already exists
+        if (slots.some((s) => s.name === slotName && s.parent === parentPath))
+          continue
+
+        slots.push({
+          name: slotName,
+          parent: parentPath,
+        })
+        break
+      }
+    }
+  }
+
+  return slots
+}
+
+/**
+ * Process app routes from mapped app pages
+ * @param mappedAppPages - The mapped app pages object
+ * @param baseDir - The base directory path
+ * @returns Array of route information
+ */
+export function processAppRoutes(
+  mappedAppPages: { [page: string]: string },
+  baseDir: string
+): RouteInfo[] {
+  const appRoutes: RouteInfo[] = []
+
+  for (const [route, filePath] of Object.entries(mappedAppPages)) {
+    if (route === '/_not-found/page') continue
+
+    const relativeFilePath = createRelativeFilePath(baseDir, filePath, 'app')
+
+    appRoutes.push({
+      route: normalizeAppPath(normalizePathSep(route)),
+      filePath: relativeFilePath,
+    })
+  }
+
+  return appRoutes
+}
+
+/**
+ * Process layout routes from mapped app layouts
+ * @param mappedAppLayouts - The mapped app layouts object
+ * @param baseDir - The base directory path
+ * @returns Array of layout route information
+ */
+export function processLayoutRoutes(
+  mappedAppLayouts: { [page: string]: string },
+  baseDir: string
+): RouteInfo[] {
+  const layoutRoutes: RouteInfo[] = []
+
+  for (const [route, filePath] of Object.entries(mappedAppLayouts)) {
+    const relativeFilePath = createRelativeFilePath(baseDir, filePath, 'app')
+    layoutRoutes.push({
+      route: ensureLeadingSlash(
+        normalizeAppPath(normalizePathSep(route)).replace(/\/layout$/, '')
+      ),
+      filePath: relativeFilePath,
+    })
+  }
+
+  return layoutRoutes
+}
 
 export function sortByPageExts(pageExtensions: PageExtensions) {
   return (a: string, b: string) => {

filename: packages/next/src/build/index.ts
patch: @@ -108,8 +108,15 @@ import type { EventBuildFeatureUsage } from '../telemetry/events'
 import { Telemetry } from '../telemetry/storage'
 import {
   createPagesMapping,
+  collectAppFiles,
   getStaticInfoIncludingLayouts,
   sortByPageExts,
+  processPageRoutes,
+  processAppRoutes,
+  processLayoutRoutes,
+  extractSlotsFromAppRoutes,
+  type RouteInfo,
+  type SlotInfo,
 } from './entries'
 import { PAGE_TYPES } from '../lib/page-types'
 import { generateBuildId } from './generate-build-id'
@@ -214,8 +221,6 @@ import {
   createRouteTypesManifest,
   writeRouteTypesManifest,
 } from '../server/lib/router-utils/route-types-utils'
-import { isParallelRouteSegment } from '../shared/lib/segment'
-import { ensureLeadingSlash } from '../shared/lib/page-path/ensure-leading-slash'
 
 type Fallback = null | boolean | string
 
@@ -1209,29 +1214,12 @@ export default async function build(
           layoutPaths = []
         } else {
           // Collect both app pages and layouts in a single directory traversal
-          const allAppFiles = await nextBuildSpan
+          const result = await nextBuildSpan
             .traceChild('collect-app-files')
-            .traceAsyncFn(() =>
-              recursiveReadDir(appDir, {
-                pathnameFilter: (absolutePath) =>
-                  validFileMatcher.isAppRouterPage(absolutePath) ||
-                  // For now we only collect the root /not-found page in the app
-                  // directory as the 404 fallback
-                  validFileMatcher.isRootNotFound(absolutePath) ||
-                  validFileMatcher.isAppLayoutPage(absolutePath),
-                ignorePartFilter: (part) => part.startsWith('_'),
-              })
-            )
+            .traceAsyncFn(() => collectAppFiles(appDir, config.pageExtensions))
 
-          // Separate app pages from layouts
-          appPaths = allAppFiles.filter(
-            (absolutePath) =>
-              validFileMatcher.isAppRouterPage(absolutePath) ||
-              validFileMatcher.isRootNotFound(absolutePath)
-          )
-          layoutPaths = allAppFiles.filter((absolutePath) =>
-            validFileMatcher.isAppLayoutPage(absolutePath)
-          )
+          appPaths = result.appPaths
+          layoutPaths = result.layoutPaths
         }
 
         mappedAppPages = await nextBuildSpan
@@ -1314,73 +1302,28 @@ export default async function build(
           const routeTypesFilePath = path.join(distDir, 'types', 'routes.d.ts')
           await fs.mkdir(path.dirname(routeTypesFilePath), { recursive: true })
 
-          const pageRoutes: Array<{ route: string; filePath: string }> = []
-          const appRoutes: Array<{ route: string; filePath: string }> = []
-          const layoutRoutes: Array<{ route: string; filePath: string }> = []
-          const slots: Array<{ name: string; parent: string }> = []
+          let pageRoutes: RouteInfo[] = []
+          let appRoutes: RouteInfo[] = []
+          let layoutRoutes: RouteInfo[] = []
+          let slots: SlotInfo[] = []
 
           // Build pages routes
-          for (const [route, filePath] of Object.entries(mappedPages)) {
-            // Only filter out _app, _error, _document, and API routes
-            // (TODO) should we filter out API routes?
-            if (isReservedPage(route)) continue
-
-            pageRoutes.push({
-              route: normalizePathSep(route),
-              filePath: filePath.replace(/^private-next-pages\//, 'pages/'),
-            })
-          }
+          const processedPages = processPageRoutes(mappedPages, dir)
+          // We combine both page routes and API routes
+          pageRoutes = [
+            ...processedPages.pageRoutes,
+            ...processedPages.pageApiRoutes,
+          ]
 
           // Build app routes
           if (appDir && mappedAppPages) {
-            for (const [route, filePath] of Object.entries(mappedAppPages)) {
-              if (route === '/_not-found/page') continue
-
-              const segments = route.split('/')
-              for (let i = segments.length - 1; i >= 0; i--) {
-                const segment = segments[i]
-                if (isParallelRouteSegment(segment)) {
-                  const parentPath = normalizeAppPath(
-                    segments.slice(0, i).join('/')
-                  )
-
-                  const slotName = segment.slice(1)
-                  // check if the slot already exists
-                  if (
-                    slots.some(
-                      (s) => s.name === slotName && s.parent === parentPath
-                    )
-                  )
-                    continue
-
-                  slots.push({
-                    name: slotName,
-                    parent: parentPath,
-                  })
-                  break
-                }
-              }
-
-              appRoutes.push({
-                route: normalizeAppPath(normalizePathSep(route)),
-                filePath: filePath.replace(/^private-next-app-dir\//, 'app/'),
-              })
-            }
+            slots = extractSlotsFromAppRoutes(mappedAppPages)
+            appRoutes = processAppRoutes(mappedAppPages, dir)
           }
 
           // Build app layouts
           if (appDir && mappedAppLayouts) {
-            for (const [route, filePath] of Object.entries(mappedAppLayouts)) {
-              layoutRoutes.push({
-                route: ensureLeadingSlash(
-                  normalizeAppPath(normalizePathSep(route)).replace(
-                    /\/layout$/,
-                    ''
-                  )
-                ),
-                filePath: filePath.replace(/^private-next-app-dir\//, 'app/'),
-              })
-            }
+            layoutRoutes = processLayoutRoutes(mappedAppLayouts, dir)
           }
 
           const routeTypesManifest = await createRouteTypesManifest({

filename: packages/next/src/cli/next-typegen.ts
patch: @@ -0,0 +1,137 @@
+#!/usr/bin/env node
+
+import { existsSync } from 'fs'
+import { join } from 'path'
+import { mkdir } from 'fs/promises'
+
+import loadConfig from '../server/config'
+import { printAndExit } from '../server/lib/utils'
+import { PHASE_PRODUCTION_BUILD } from '../shared/lib/constants'
+import { getProjectDir } from '../lib/get-project-dir'
+import { findPagesDir } from '../lib/find-pages-dir'
+import { verifyTypeScriptSetup } from '../lib/verify-typescript-setup'
+import {
+  createPagesMapping,
+  collectAppFiles,
+  collectPagesFiles,
+  processPageRoutes,
+  processAppRoutes,
+  processLayoutRoutes,
+  extractSlotsFromAppRoutes,
+  type RouteInfo,
+  type SlotInfo,
+} from '../build/entries'
+import { PAGE_TYPES } from '../lib/page-types'
+
+import {
+  createRouteTypesManifest,
+  writeRouteTypesManifest,
+} from '../server/lib/router-utils/route-types-utils'
+
+export type NextTypegenOptions = {
+  dir?: string
+}
+
+const nextTypegen = async (
+  _options: NextTypegenOptions,
+  directory?: string
+) => {
+  const baseDir = getProjectDir(directory)
+
+  // Check if the provided directory exists
+  if (!existsSync(baseDir)) {
+    printAndExit(`> No such directory exists as the project root: ${baseDir}`)
+  }
+
+  const nextConfig = await loadConfig(PHASE_PRODUCTION_BUILD, baseDir)
+  const distDir = join(baseDir, nextConfig.distDir)
+  const { pagesDir, appDir } = findPagesDir(baseDir)
+
+  await verifyTypeScriptSetup({
+    dir: baseDir,
+    distDir: nextConfig.distDir,
+    intentDirs: [pagesDir, appDir].filter(Boolean) as string[],
+    typeCheckPreflight: false,
+    tsconfigPath: nextConfig.typescript.tsconfigPath,
+    disableStaticImages: nextConfig.images.disableStaticImages,
+    hasAppDir: !!appDir,
+    hasPagesDir: !!pagesDir,
+  })
+
+  console.log('Generating route types...')
+
+  const routeTypesFilePath = join(distDir, 'types', 'routes.d.ts')
+  await mkdir(join(distDir, 'types'), { recursive: true })
+
+  let pageRoutes: RouteInfo[] = []
+  let appRoutes: RouteInfo[] = []
+  let layoutRoutes: RouteInfo[] = []
+  let slots: SlotInfo[] = []
+
+  let _pageApiRoutes: RouteInfo[] = []
+
+  let mappedPages: { [page: string]: string } = {}
+  let mappedAppPages: { [page: string]: string } = {}
+  let mappedAppLayouts: { [page: string]: string } = {}
+
+  // Helper function to reduce createPagesMapping duplication
+  const createMapping = (pagePaths: string[], pagesType: any) =>
+    createPagesMapping({
+      pagePaths,
+      isDev: false,
+      pagesType,
+      pageExtensions: nextConfig.pageExtensions,
+      pagesDir,
+      appDir,
+    })
+
+  // Build pages routes
+  if (pagesDir) {
+    const pagePaths = await collectPagesFiles(
+      pagesDir,
+      nextConfig.pageExtensions
+    )
+
+    mappedPages = await createMapping(pagePaths, PAGE_TYPES.PAGES)
+
+    // Process pages routes
+    const processedPages = processPageRoutes(mappedPages, baseDir)
+    pageRoutes = processedPages.pageRoutes
+    _pageApiRoutes = processedPages.pageApiRoutes
+  }
+
+  // Build app routes
+  if (appDir) {
+    // Collect both app pages and layouts in a single directory traversal
+    const { appPaths, layoutPaths } = await collectAppFiles(
+      appDir,
+      nextConfig.pageExtensions
+    )
+
+    mappedAppPages = await createMapping(appPaths, PAGE_TYPES.APP)
+    mappedAppLayouts = await createMapping(layoutPaths, PAGE_TYPES.APP)
+
+    // Process app routes and extract slots
+    slots = extractSlotsFromAppRoutes(mappedAppPages)
+    appRoutes = processAppRoutes(mappedAppPages, baseDir)
+
+    // Process layout routes
+    layoutRoutes = processLayoutRoutes(mappedAppLayouts, baseDir)
+  }
+
+  const routeTypesManifest = await createRouteTypesManifest({
+    dir: baseDir,
+    pageRoutes,
+    appRoutes,
+    layoutRoutes,
+    slots,
+    redirects: nextConfig.redirects,
+    rewrites: nextConfig.rewrites,
+  })
+
+  await writeRouteTypesManifest(routeTypesManifest, routeTypesFilePath)
+
+  console.log('✓ Route types generated successfully')
+}
+
+export { nextTypegen }