Repository: vercel/next.js
Pull request: Ensure static metadata are static outputs for adapters

This ensures we copy over the changes from https://github.com/vercel/vercel/pull/13816 so static metadata is not output as a prerender instead of a static output. 

Changed files:
filename: packages/next/src/build/adapter/build-complete.ts
patch: @@ -40,6 +40,7 @@ import {
 } from '../../lib/constants'
 
 import { normalizeLocalePath } from '../../shared/lib/i18n/normalize-locale-path'
+import { isStaticMetadataFile } from '../../lib/metadata/is-metadata-route'
 import { addPathPrefix } from '../../shared/lib/router/utils/add-path-prefix'
 import { getRedirectStatus, modifyRouteRegex } from '../../lib/redirect-status'
 import { getNamedRouteRegex } from '../../shared/lib/router/utils/route-regex'
@@ -1005,6 +1006,11 @@ export async function handleBuildComplete({
             continue
           }
           const normalizedPage = normalizeAppPath(page)
+
+          // Skip static metadata routes - they will be output as static files
+          if (isStaticMetadataFile(normalizedPage)) {
+            continue
+          }
           const pageFile = path.join(appDistDir, `${page}.js`)
           const pageTraceFile = `${pageFile}.nft.json`
           const assets = await handleTraceFiles(pageTraceFile, 'app').catch(
@@ -1290,6 +1296,25 @@ export async function handleBuildComplete({
           `${normalizePagePath(route)}.${isAppPage && !dataRoute ? 'body' : 'html'}`
         )
 
+        // Check if this is a static metadata route (e.g., /favicon.ico, /icon.png, /opengraph-image.png)
+        // These should be output as static files, not prerenders.
+        if (isStaticMetadataFile(route)) {
+          // For static metadata from app router, check if the .body file exists
+          const staticMetadataFilePath = path.join(
+            appDistDir,
+            `${normalizePagePath(route)}.body`
+          )
+          if (await cachedFilePathCheck(staticMetadataFilePath)) {
+            outputs.staticFiles.push({
+              id: route,
+              pathname: route,
+              type: AdapterOutputType.STATIC_FILE,
+              filePath: staticMetadataFilePath,
+            })
+            continue
+          }
+        }
+
         // we use the static 404 for notFound: true if available
         // if not we do a blocking invoke on first request
         if (isNotFoundTrue && hasStatic404) {

filename: test/production/adapter-config/adapter-config.test.ts
patch: @@ -96,6 +96,11 @@ describe('adapter-config', () => {
         expect(output.pathname.endsWith('.html')).toBe(false)
       } else if (output.pathname.endsWith('.rsc')) {
         expect(output.filePath.endsWith('rsc-fallback.json')).toBe(true)
+      } else if (output.filePath.endsWith('.body')) {
+        // Static metadata files (e.g., /icon.png, /manifest.json, /sitemap.xml) are output as static files
+        expect(output.pathname).toMatch(
+          /\.(png|jpg|jpeg|ico|svg|gif|json|webmanifest|xml|txt)$/
+        )
       } else {
         expect(output.pathname).toStartWith('/docs/_next/static')
       }
@@ -106,6 +111,123 @@ describe('adapter-config', () => {
       expect(stats.isFile()).toBe(true)
     }
 
+    // Verify static metadata files are output as static files, not prerenders
+    // Test icon.png
+    const iconStaticFile = staticOutputs.find(
+      (item) => item.pathname === '/docs/icon.png'
+    )
+    expect(iconStaticFile).toBeDefined()
+    expect(iconStaticFile?.filePath).toMatch(/\.body$/)
+
+    // Static metadata images should NOT be in prerenders
+    const iconPrerender = prerenderOutputs.find(
+      (item) => item.pathname === '/docs/icon.png'
+    )
+    expect(iconPrerender).toBeUndefined()
+
+    // Static metadata images should NOT be in appRoutes
+    const iconAppRoute = outputs.appRoutes.find(
+      (item) =>
+        item.pathname === '/docs/icon.png' ||
+        item.pathname === '/docs/icon.png.rsc'
+    )
+    expect(iconAppRoute).toBeUndefined()
+
+    // Test favicon.ico
+    const faviconStaticFile = staticOutputs.find(
+      (item) => item.pathname === '/docs/favicon.ico'
+    )
+    expect(faviconStaticFile).toBeDefined()
+    expect(faviconStaticFile?.filePath).toMatch(/\.body$/)
+
+    const faviconPrerender = prerenderOutputs.find(
+      (item) => item.pathname === '/docs/favicon.ico'
+    )
+    expect(faviconPrerender).toBeUndefined()
+
+    const faviconAppRoute = outputs.appRoutes.find(
+      (item) =>
+        item.pathname === '/docs/favicon.ico' ||
+        item.pathname === '/docs/favicon.ico.rsc'
+    )
+    expect(faviconAppRoute).toBeUndefined()
+
+    // Test opengraph-image.png
+    const ogImageStaticFile = staticOutputs.find(
+      (item) => item.pathname === '/docs/opengraph-image.png'
+    )
+    expect(ogImageStaticFile).toBeDefined()
+    expect(ogImageStaticFile?.filePath).toMatch(/\.body$/)
+
+    const ogImagePrerender = prerenderOutputs.find(
+      (item) => item.pathname === '/docs/opengraph-image.png'
+    )
+    expect(ogImagePrerender).toBeUndefined()
+
+    const ogImageAppRoute = outputs.appRoutes.find(
+      (item) =>
+        item.pathname === '/docs/opengraph-image.png' ||
+        item.pathname === '/docs/opengraph-image.png.rsc'
+    )
+    expect(ogImageAppRoute).toBeUndefined()
+
+    // Test manifest.json
+    const manifestStaticFile = staticOutputs.find(
+      (item) => item.pathname === '/docs/manifest.json'
+    )
+    expect(manifestStaticFile).toBeDefined()
+    expect(manifestStaticFile?.filePath).toMatch(/\.body$/)
+
+    const manifestPrerender = prerenderOutputs.find(
+      (item) => item.pathname === '/docs/manifest.json'
+    )
+    expect(manifestPrerender).toBeUndefined()
+
+    const manifestAppRoute = outputs.appRoutes.find(
+      (item) =>
+        item.pathname === '/docs/manifest.json' ||
+        item.pathname === '/docs/manifest.json.rsc'
+    )
+    expect(manifestAppRoute).toBeUndefined()
+
+    // Test sitemap.xml
+    const sitemapStaticFile = staticOutputs.find(
+      (item) => item.pathname === '/docs/sitemap.xml'
+    )
+    expect(sitemapStaticFile).toBeDefined()
+    expect(sitemapStaticFile?.filePath).toMatch(/\.body$/)
+
+    const sitemapPrerender = prerenderOutputs.find(
+      (item) => item.pathname === '/docs/sitemap.xml'
+    )
+    expect(sitemapPrerender).toBeUndefined()
+
+    const sitemapAppRoute = outputs.appRoutes.find(
+      (item) =>
+        item.pathname === '/docs/sitemap.xml' ||
+        item.pathname === '/docs/sitemap.xml.rsc'
+    )
+    expect(sitemapAppRoute).toBeUndefined()
+
+    // Test robots.txt
+    const robotsStaticFile = staticOutputs.find(
+      (item) => item.pathname === '/docs/robots.txt'
+    )
+    expect(robotsStaticFile).toBeDefined()
+    expect(robotsStaticFile?.filePath).toMatch(/\.body$/)
+
+    const robotsPrerender = prerenderOutputs.find(
+      (item) => item.pathname === '/docs/robots.txt'
+    )
+    expect(robotsPrerender).toBeUndefined()
+
+    const robotsAppRoute = outputs.appRoutes.find(
+      (item) =>
+        item.pathname === '/docs/robots.txt' ||
+        item.pathname === '/docs/robots.txt.rsc'
+    )
+    expect(robotsAppRoute).toBeUndefined()
+
     for (const prerenderOutput of prerenderOutputs) {
       try {
         expect(prerenderOutput.parentOutputId).toBeTruthy()

filename: test/production/adapter-config/app/manifest.json
patch: @@ -0,0 +1,6 @@
+{
+  "name": "Test App",
+  "short_name": "Test",
+  "start_url": "/",
+  "display": "standalone"
+}

filename: test/production/adapter-config/app/robots.txt
patch: @@ -0,0 +1,2 @@
+User-agent: *
+Allow: /

filename: test/production/adapter-config/app/sitemap.xml
patch: @@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+  <url>
+    <loc>https://example.com/</loc>
+  </url>
+</urlset>