Skip to content

Commit 600cd2f

Browse files
committed
Fix adapter outputs for dynamic metadata routes (#91680)
## Summary This fixes adapter `onBuildComplete` outputs for dynamic metadata routes. ## Bug `handleBuildComplete` skipped metadata routes too aggressively via `isStaticMetadataFile(...)`, so dynamic metadata routes (for example `robots.txt` / `sitemap.xml` using `connection()`) were omitted from `outputs.appRoutes`. ## Fix Only skip metadata routes when they are actually prerendered/static (present in prerender manifests). Dynamic metadata routes are now included in adapter `outputs.appRoutes` as expected. ## Tests Added e2e coverage in `test/e2e/app-dir/adapter-dynamic-metadata`: - verifies dynamic `robots.txt`, `sitemap.xml`, and `favicon.ico` functionality - verifies adapter output classification for non-deploy runs - skips output-shape verification in deploy mode - skips the suite in dev mode
1 parent 27886d3 commit 600cd2f

9 files changed

Lines changed: 157 additions & 2 deletions

File tree

packages/next/src/build/adapter/build-complete.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,8 +1070,26 @@ export async function handleBuildComplete({
10701070
}
10711071
const normalizedPage = normalizeAppPath(page)
10721072

1073-
// Skip static metadata routes - they will be output as static files
1074-
if (isStaticMetadataFile(normalizedPage)) {
1073+
// Skip static metadata routes only when they are prerendered.
1074+
// Dynamic metadata routes (e.g. robots/sitemap using connection())
1075+
// should remain app routes in adapter outputs.
1076+
const isStaticMetadataRoute = isStaticMetadataFile(normalizedPage)
1077+
const isPrerenderedMetadataRoute =
1078+
prerenderManifest.routes[normalizedPage] ||
1079+
prerenderManifest.dynamicRoutes[normalizedPage] ||
1080+
config.i18n?.locales?.some((locale) => {
1081+
const localePathname = path.posix.join(
1082+
'/',
1083+
locale,
1084+
normalizedPage.slice(1)
1085+
)
1086+
return (
1087+
prerenderManifest.routes[localePathname] ||
1088+
prerenderManifest.dynamicRoutes[localePathname]
1089+
)
1090+
})
1091+
1092+
if (isStaticMetadataRoute && isPrerenderedMetadataRoute) {
10751093
continue
10761094
}
10771095
const pageFile = path.join(appDistDir, `${page}.js`)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { NextAdapter } from 'next'
2+
import { nextTestSetup } from 'e2e-utils'
3+
4+
describe('adapter-dynamic-metadata', () => {
5+
const { next, isNextDev, isNextDeploy } = nextTestSetup({
6+
files: __dirname,
7+
})
8+
9+
if (isNextDev) {
10+
it('should skip next dev', () => {})
11+
return
12+
}
13+
14+
if (!isNextDeploy) {
15+
it('should classify dynamic metadata routes as app routes in adapter outputs', async () => {
16+
const { outputs }: Parameters<NextAdapter['onBuildComplete']>[0] =
17+
await next.readJSON('build-complete.json')
18+
19+
const expectedDynamicMetadataRoutes = [
20+
'/robots.txt',
21+
'/sitemap.xml',
22+
'/favicon.ico',
23+
]
24+
const staticFilePathnames = outputs.staticFiles.map(
25+
(item) => item.pathname
26+
)
27+
const prerenderPathnames = outputs.prerenders.map((item) => item.pathname)
28+
const appRoutePathnames = outputs.appRoutes.map((item) => item.pathname)
29+
30+
expect(staticFilePathnames).toEqual(
31+
expect.not.arrayContaining(expectedDynamicMetadataRoutes)
32+
)
33+
expect(prerenderPathnames).toEqual(
34+
expect.not.arrayContaining(expectedDynamicMetadataRoutes)
35+
)
36+
expect(appRoutePathnames).toEqual(
37+
expect.arrayContaining(expectedDynamicMetadataRoutes)
38+
)
39+
40+
for (const pathname of expectedDynamicMetadataRoutes) {
41+
const appRoute = outputs.appRoutes.find(
42+
(item) => item.pathname === pathname
43+
)
44+
expect(appRoute?.runtime).toBe('nodejs')
45+
}
46+
})
47+
}
48+
49+
it('should serve dynamic metadata routes', async () => {
50+
const robots = await next.fetch('/robots.txt')
51+
expect(robots.status).toBe(200)
52+
expect(robots.headers.get('content-type')).toContain('text/plain')
53+
expect(await robots.text()).toContain('User-Agent: *')
54+
55+
const sitemap = await next.fetch('/sitemap.xml')
56+
expect(sitemap.status).toBe(200)
57+
expect(sitemap.headers.get('content-type')).toBe('application/xml')
58+
expect(await sitemap.text()).toContain('<urlset')
59+
60+
const favicon = await next.fetch('/favicon.ico')
61+
expect(favicon.status).toBe(200)
62+
expect(favicon.headers.get('content-type')).toContain('image/x-icon')
63+
expect(await favicon.text()).toContain('dynamic favicon')
64+
})
65+
})
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { connection } from 'next/server'
2+
3+
export async function GET() {
4+
await connection()
5+
6+
return new Response('dynamic favicon', {
7+
headers: {
8+
'content-type': 'image/x-icon',
9+
},
10+
})
11+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ReactNode } from 'react'
2+
export default function Root({ children }: { children: ReactNode }) {
3+
return (
4+
<html>
5+
<body>{children}</body>
6+
</html>
7+
)
8+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Page() {
2+
return <p>hello world</p>
3+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { MetadataRoute } from 'next'
2+
import { connection } from 'next/server'
3+
4+
export default async function robots(): Promise<MetadataRoute.Robots> {
5+
await connection()
6+
7+
return {
8+
rules: {
9+
userAgent: '*',
10+
allow: '/',
11+
},
12+
sitemap: 'https://example.com/sitemap.xml',
13+
}
14+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { MetadataRoute } from 'next'
2+
import { connection } from 'next/server'
3+
4+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
5+
await connection()
6+
7+
return [
8+
{
9+
url: 'https://example.com',
10+
},
11+
]
12+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import fs from 'fs'
2+
3+
/** @type {import('next').NextAdapter} */
4+
const adapter = {
5+
name: 'adapter-dynamic-metadata',
6+
onBuildComplete: async (ctx) => {
7+
await fs.promises.writeFile(
8+
'build-complete.json',
9+
JSON.stringify(ctx, null, 2)
10+
)
11+
},
12+
}
13+
14+
export default adapter
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* @type {import('next').NextConfig}
3+
*/
4+
const nextConfig = {}
5+
6+
if (!process.env.NEXT_ADAPTER_PATH) {
7+
nextConfig.adapterPath = require.resolve('./my-adapter.mjs')
8+
}
9+
10+
module.exports = nextConfig

0 commit comments

Comments
 (0)