From 3524951f2d330d506ca10f4efefd60813dfdc8d0 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Wed, 17 Sep 2025 16:09:48 +0200 Subject: [PATCH 1/7] [prod] Serve static metadata from filesystem --- packages/next/src/build/entries.ts | 62 ++++++++++++++----- packages/next/src/build/index.ts | 21 +++++++ .../src/server/lib/router-utils/filesystem.ts | 28 ++++++--- 3 files changed, 89 insertions(+), 22 deletions(-) diff --git a/packages/next/src/build/entries.ts b/packages/next/src/build/entries.ts index 3f60ae73c85fd..0de50f4cb3681 100644 --- a/packages/next/src/build/entries.ts +++ b/packages/next/src/build/entries.ts @@ -62,7 +62,10 @@ import { isInternalComponent, isNonRoutePagesPage, } from '../lib/is-internal-component' -import { isMetadataRouteFile } from '../lib/metadata/is-metadata-route' +import { + isMetadataRouteFile, + isStaticMetadataFile, +} from '../lib/metadata/is-metadata-route' import { RouteKind } from '../server/route-kind' import { encodeToBase64 } from './webpack/loaders/utils' import { normalizeCatchAllRoutes } from './normalize-catchall-routes' @@ -94,8 +97,10 @@ export async function collectAppFiles( appPaths: string[] layoutPaths: string[] defaultPaths: string[] + staticMetadataFiles: Map }> { // Collect app pages, layouts, and default files in a single directory traversal + // Returns relative paths. const allAppFiles = await recursiveReadDir(appDir, { pathnameFilter: (absolutePath) => validFileMatcher.isAppRouterPage(absolutePath) || @@ -105,20 +110,49 @@ export async function collectAppFiles( ignorePartFilter: (part) => part.startsWith('_'), }) - // Separate app pages, layouts, and defaults - const appPaths = allAppFiles.filter( - (absolutePath) => - validFileMatcher.isAppRouterPage(absolutePath) || - validFileMatcher.isRootNotFound(absolutePath) - ) - const layoutPaths = allAppFiles.filter((absolutePath) => - validFileMatcher.isAppLayoutPage(absolutePath) - ) - const defaultPaths = allAppFiles.filter((absolutePath) => - validFileMatcher.isAppDefaultPage(absolutePath) - ) + // Separate app pages, layouts, defaults, and static metadata files + const appPaths = [] + const layoutPaths = [] + const defaultPaths = [] + // requestPath => relativePath + const staticMetadataFiles = new Map() + + for (const relativePath of allAppFiles) { + if (validFileMatcher.isAppRouterPage(relativePath)) { + appPaths.push(relativePath) + + // Static metadata files (favicon.ico, icon.png, etc.) need to be served + // directly from the filesystem. To enable this, we create a mapping from + // request paths to their actual file locations. + if (isStaticMetadataFile(relativePath)) { + let requestPath = relativePath + // Adds metadata file suffix and "/route" suffix + // "/@parallel/icon.png" -> "/@parallel/icon-.png/route" + requestPath = normalizeMetadataRoute(requestPath) + // Normalizes to app route request path + // "/@parallel/icon-.png/route" -> "/icon-.png" + requestPath = normalizeAppPath(requestPath) + // Converts underscore encoding + requestPath = requestPath.replace(/%5F/g, '_') + // Maps the final request path to the file location + staticMetadataFiles.set(requestPath, relativePath) + } + } + + if (validFileMatcher.isRootNotFound(relativePath)) { + appPaths.push(relativePath) + } + + if (validFileMatcher.isAppLayoutPage(relativePath)) { + layoutPaths.push(relativePath) + } + + if (validFileMatcher.isAppDefaultPage(relativePath)) { + defaultPaths.push(relativePath) + } + } - return { appPaths, layoutPaths, defaultPaths } + return { appPaths, layoutPaths, defaultPaths, staticMetadataFiles } } /** diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index fab1bccb6e6c5..c8233b611aa56 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -1236,6 +1236,27 @@ export default async function build( appPaths = result.appPaths layoutPaths = result.layoutPaths // Note: defaultPaths are not used in the build process, only for slot detection in generating route types + + // Copy static metadata files for production filesystem serving. + // Not using a loader so that it works on all bundlers. + if (result.staticMetadataFiles.size > 0) { + await nextBuildSpan + .traceChild('copy-static-metadata-files') + .traceAsyncFn(async () => { + for (const [ + requestPath, + relativePath, + ] of result.staticMetadataFiles) { + const outPath = path.join( + distDir, + 'static/metadata', + requestPath + ) + await fs.mkdir(path.dirname(outPath), { recursive: true }) + await fs.copyFile(path.join(appDir, relativePath), outPath) + } + }) + } } mappedAppPages = await nextBuildSpan diff --git a/packages/next/src/server/lib/router-utils/filesystem.ts b/packages/next/src/server/lib/router-utils/filesystem.ts index cd276cd69eb58..99580d75dc408 100644 --- a/packages/next/src/server/lib/router-utils/filesystem.ts +++ b/packages/next/src/server/lib/router-utils/filesystem.ts @@ -507,14 +507,26 @@ export async function setupFsCheck(opts: { } } - if (opts.dev && isMetadataRouteFile(itemPath, [], false)) { - const fsPath = staticMetadataFiles.get(itemPath) - if (fsPath) { - return { - // "nextStaticFolder" sets Cache-Control "no-store" on dev. - type: 'nextStaticFolder', - fsPath, - itemPath: fsPath, + if (isMetadataRouteFile(itemPath, [], false)) { + if (opts.dev) { + const fsPath = staticMetadataFiles.get(itemPath) + if (fsPath) { + return { + // "nextStaticFolder" sets Cache-Control "no-store" on dev. + type: 'nextStaticFolder', + fsPath, + itemPath: fsPath, + } + } + } else { + const reqPath = path.join('/_next/static/metadata', itemPath) + if (nextStaticFolderItems.has(reqPath)) { + const fsPath = path.join(distDir, 'static/metadata', itemPath) + return { + type: 'nextStaticFolder', + fsPath, + itemPath: fsPath, + } } } } From 054b79b9961bf461d4662098194085c7c9150673 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Wed, 17 Sep 2025 16:51:19 +0200 Subject: [PATCH 2/7] test: modify util for integration/app-dir-export/test/trailing-slash-start.test.ts --- test/integration/app-dir-export/test/utils.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/integration/app-dir-export/test/utils.ts b/test/integration/app-dir-export/test/utils.ts index df6a21aca783e..a014fff3f552d 100644 --- a/test/integration/app-dir-export/test/utils.ts +++ b/test/integration/app-dir-export/test/utils.ts @@ -37,6 +37,8 @@ export const expectedWhenTrailingSlashTrue = [ ? [expect.stringMatching(/_next\/static\/media\/favicon\.[0-9a-f]+\.ico/)] : []), expect.stringMatching(/_next\/static\/media\/test\.[0-9a-f]+\.png/), + '_next/static/metadata/favicon.ico', + '_next/static/metadata/robots.txt', '_next/static/test-build-id/_buildManifest.js', ...(process.env.IS_TURBOPACK_TEST ? ['_next/static/test-build-id/_clientMiddlewareManifest.json'] @@ -69,6 +71,8 @@ const expectedWhenTrailingSlashFalse = [ ? [expect.stringMatching(/_next\/static\/media\/favicon\.[0-9a-f]+\.ico/)] : []), expect.stringMatching(/_next\/static\/media\/test\.[0-9a-f]+\.png/), + '_next/static/metadata/favicon.ico', + '_next/static/metadata/robots.txt', '_next/static/test-build-id/_buildManifest.js', ...(process.env.IS_TURBOPACK_TEST ? ['_next/static/test-build-id/_clientMiddlewareManifest.json'] From 839fcea997e59dbc46382b909ce5310bbf851354 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Wed, 17 Sep 2025 16:56:59 +0200 Subject: [PATCH 3/7] set revalidate header --- packages/next/src/server/lib/router-server.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/next/src/server/lib/router-server.ts b/packages/next/src/server/lib/router-server.ts index 1672cbd50360e..adaac718baa7b 100644 --- a/packages/next/src/server/lib/router-server.ts +++ b/packages/next/src/server/lib/router-server.ts @@ -59,6 +59,7 @@ import { handleChromeDevtoolsWorkspaceRequest, isChromeDevtoolsWorkspaceUrl, } from './chrome-devtools-workspace' +import { isStaticMetadataFile } from '../../lib/metadata/is-metadata-route' const debug = setupDebug('next:router-server:main') const isNextFont = (pathname: string | null) => @@ -475,6 +476,19 @@ export async function initialize(opts: { ) { if (opts.dev && !isNextFont(parsedUrl.pathname)) { res.setHeader('Cache-Control', 'no-store, must-revalidate') + } else if ( + isStaticMetadataFile( + // isStaticMetadataFile expects the app dir relative path, which is equivalent + // to a path from "{distDir}/static/metadata". + matchedOutput.itemPath.replace( + path.join(opts.dir, config.distDir, 'static/metadata'), + '' + ) + ) + ) { + // Static metadata files should be revalidated. This matches the behavior of + // the dynamic metadata files e.g., icon.tsx, sitemap.ts, etc. + res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate') } else { res.setHeader( 'Cache-Control', From 842ca1ae01a01d485051e4dfe926b0b159d82b6f Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Wed, 17 Sep 2025 16:57:20 +0200 Subject: [PATCH 4/7] test: update e2e/app-dir/metadata/metadata.test.ts --- test/e2e/app-dir/metadata/metadata.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/e2e/app-dir/metadata/metadata.test.ts b/test/e2e/app-dir/metadata/metadata.test.ts index d0cf14e37656f..6a725da4956e7 100644 --- a/test/e2e/app-dir/metadata/metadata.test.ts +++ b/test/e2e/app-dir/metadata/metadata.test.ts @@ -710,10 +710,7 @@ describe('app dir - metadata', () => { it('should support root dir robots.txt', async () => { const res = await next.fetch('/robots.txt') - expect(res.headers.get('content-type')).toBe( - // In dev, sendStatic() is used to send static files, which adds MIME type. - isNextDev ? 'text/plain; charset=UTF-8' : 'text/plain' - ) + expect(res.headers.get('content-type')).toBe('text/plain; charset=UTF-8') expect(await res.text()).toContain('User-Agent: *\nDisallow:') const invalidRobotsResponse = await next.fetch('/title/robots.txt') expect(invalidRobotsResponse.status).toBe(404) From e166abebe3d4b249df56080b3ac8e59805982c7a Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Wed, 17 Sep 2025 17:12:09 +0200 Subject: [PATCH 5/7] update comment --- packages/next/src/build/entries.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/next/src/build/entries.ts b/packages/next/src/build/entries.ts index 0de50f4cb3681..8cb61fcac409f 100644 --- a/packages/next/src/build/entries.ts +++ b/packages/next/src/build/entries.ts @@ -114,7 +114,10 @@ export async function collectAppFiles( const appPaths = [] const layoutPaths = [] const defaultPaths = [] - // requestPath => relativePath + // Map of "requestPath" => "relativePath". + // "requestPath" will be used for the output path "{distDir}/static/metadata/{requestPath}" and + // its matcher. When the request comes in, the filesystem handler will look for the output path + // and serve the file if exists. "relativePath" will be used to copy the file to the output path. const staticMetadataFiles = new Map() for (const relativePath of allAppFiles) { From 5b0277af69e4cadcd19820bb3178521d63ee273e Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Wed, 17 Sep 2025 18:00:43 +0200 Subject: [PATCH 6/7] test: e2e/app-dir/metadata/metadata.test.ts deploy uses route.js .body file --- test/e2e/app-dir/metadata/metadata.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/e2e/app-dir/metadata/metadata.test.ts b/test/e2e/app-dir/metadata/metadata.test.ts index 6a725da4956e7..9cc45e3bd88ed 100644 --- a/test/e2e/app-dir/metadata/metadata.test.ts +++ b/test/e2e/app-dir/metadata/metadata.test.ts @@ -710,7 +710,11 @@ describe('app dir - metadata', () => { it('should support root dir robots.txt', async () => { const res = await next.fetch('/robots.txt') - expect(res.headers.get('content-type')).toBe('text/plain; charset=UTF-8') + expect(res.headers.get('content-type')).toBe( + // In deploy, it still uses route.js to serve metadata file. + // Otherwise, it uses sendStatic() to send static files, which adds MIME type. + isNextDeploy ? 'text/plain' : 'text/plain; charset=UTF-8' + ) expect(await res.text()).toContain('User-Agent: *\nDisallow:') const invalidRobotsResponse = await next.fetch('/title/robots.txt') expect(invalidRobotsResponse.status).toBe(404) From 17254a00fb8bb47b60d141adbb45c72e811878bb Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Wed, 17 Sep 2025 19:08:32 +0200 Subject: [PATCH 7/7] use posix --- packages/next/src/server/lib/router-utils/filesystem.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/src/server/lib/router-utils/filesystem.ts b/packages/next/src/server/lib/router-utils/filesystem.ts index 99580d75dc408..facc6a0f5f1df 100644 --- a/packages/next/src/server/lib/router-utils/filesystem.ts +++ b/packages/next/src/server/lib/router-utils/filesystem.ts @@ -519,7 +519,7 @@ export async function setupFsCheck(opts: { } } } else { - const reqPath = path.join('/_next/static/metadata', itemPath) + const reqPath = path.posix.join('/_next/static/metadata', itemPath) if (nextStaticFolderItems.has(reqPath)) { const fsPath = path.join(distDir, 'static/metadata', itemPath) return {