[Bug] Static metadata (export const metadata) rendered in <body> instead of <head> in 15.5.4
Link to the code that reproduces this issue
https://github.com/fabiotheo/nextjs-metadata-bug
To Reproduce
Step 1: Clone the reproduction repository
git clone https://github.com/fabiotheo/nextjs-metadata-bug
cd nextjs-metadata-bug
npm install
Step 2: Start development server
npm run dev
Step 3: Inspect HTML
1. Open http://localhost:3000
2. Open DevTools (F12)
3. Inspect the HTML structure
4. Search for meta tags (e.g., og:title, fb:app_id, twitter:card)
5. Notice all meta tags are rendered inside <body> instead of <head>
### Current vs. Expected behavior
```markdown
## Current Behavior (15.5.4) - BROKEN ❌
All metadata tags defined via `export const metadata` are incorrectly rendered **inside `<body>`** instead of `<head>`:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Only basic Next.js meta tags, no custom metadata -->
</head>
<body>
<!-- ❌ WRONG: All custom metadata appears here -->
<title>Test App</title>
<meta name="description" content="Testing metadata bug in Next.js 15.5.4">
<meta property="og:title" content="Test App">
<meta property="fb:app_id" content="123456789">
<meta name="twitter:card" content="summary_large_image">
<!-- etc. -->
</body>
</html>
Expected Behavior (15.2.4) - CORRECT ✅
All metadata tags should be rendered inside <head>
Impact
This bug causes severe SEO and social sharing issues:
1. Open Graph broken: Facebook, LinkedIn, WhatsApp ignore meta tags in <body>
2. Twitter Cards broken: Twitter expects meta tags in <head>
3. SEO issues: Search engines may not properly parse metadata from <body>
4. HTML validation fails: Meta tags in <body> violate W3C standards
### Provide environment information
```bash
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 24.6.0: Mon Jul 14 11:30:30 PDT 2025; root:xnu-11417.140.69~1/RELEASE_ARM64_T6020
Available memory (MB): 32768
Available CPU cores: 10
Binaries:
Node: 22.19.0
npm: 10.9.3
Yarn: 1.22.22
pnpm: 10.17.1
Relevant Packages:
next: 15.5.4 // Latest available version is detected (15.5.4).
eslint-config-next: N/A
react: 19.2.0
react-dom: 19.2.0
typescript: 5.9.3
Next.js Config:
output: N/A
Which area(s) are affected? (Select all that apply)
Headers, Metadata
Which stage(s) are affected? (Select all that apply)
next dev (local), next build (local)
Additional context
Regression Details
The bug was introduced between versions:
- 15.2.4 (working) ✅
- 15.5.4 (broken) ❌
I have verified that downgrading to 15.2.4 completely resolves the issue.
Workaround
Temporarily downgrading to Next.js 15.2.4:
{
"dependencies": {
"next": "15.2.4"
}
}
Documentation Reference
From Next.js docs (https://nextjs.org/docs/app/api-reference/file-conventions/layout):
Good to know: You should not manually add <head> tags such as <title> and <meta> to root layouts. Instead, you should use the Metadata API.
The docs clearly state metadata should be in <head>, but 15.5.4 puts it in <body>.
<sub>[NEXT-4750](https://linear.app/vercel/issue/NEXT-4750/bug-static-metadata-export-const-metadata-rendered-in-body-instead-of)</sub>
This is also happening for our prod build with 15.5.4. Im working now to downgrade to see if this reverts it as suggested.
EDIT: Confirmed downgrading does fix the issue. I walked us back all the way to 15.2.x. but from what im seeing this is actually suppose to be happening if we are using generateMetaData as discussed here https://github.com/vercel/next.js/issues/79313
No problem in 15.3.2
Edit by maintainer bot: Comment was automatically minimized because it was considered unhelpful. (If you think this was by mistake, let us know). Please only comment if it adds context to the issue. If you want to express that you have the same problem, use the upvote 👍 on the issue description or subscribe to the issue for updates. Thanks!
I also have a problem with 15.3.1. However, I upgraded and downgraded, but the error still persists.
I am debugging and researching to resolve this problem.
Root Cause: Suspense Wrapper
I found and verified a change for this issue
The Bug
In metadata.tsx lines 160-169, when serveStreamingMetadata is true, metadata is wrapped in <Suspense>:
return (
<div hidden>
<MetadataBoundary>
<Suspense name="Next.Metadata">
{pendingMetadataTags}
</Suspense>
</MetadataBoundary>
</div>
)
This causes metadata to render in <body> instead of <head>.
The Fix
I removed the Suspense wrapper (commented out lines 163 and 166):
return (
<div hidden>
<MetadataBoundary>
{pendingMetadataTags} // No Suspense
</MetadataBoundary>
</div>
)
Rebuilt Next.js and tested. Result: ✅ All metadata tags now render in <head> correctly.
Verified With Multiple Tests
Tested various scenarios:
- Layout + page both have metadata
- Only layout has metadata
- Partial overlap
All work correctly with Suspense removed. Used both browser DevTools and curl to verify.
Questions
-
What is the Suspense optimizing?
- Is it to stream HTML faster?
- For synchronous
export const metadata, there's nothing async to wait for - For async
generateMetadata(), resulting metadata needs to be in head anyway
-
TODO comment: Lines 149-151 say:
// TODO: We shouldn't change what we render based on whether we are streaming or not. // If we aren't streaming we should just block the response until we have resolved the // metadata.Is this TODO being worked on?
-
Is this the right direction? Would you accept a PR that removes the Suspense wrapper? Or changes the default for
serveStreamingMetadata?
Impact
Affects all App Router apps using export const metadata in layout.tsx (the recommended pattern), breaking SEO and social sharing.
Simple Reproduction
https://github.com/robbchar/next-metadata-example
Layout-level metadata renders in <body> (while page-level metadata renders in <head>). With Suspense removed, layout metadata also renders in <head> correctly.
Happy to submit a PR with the fix if this approach makes sense!
UPDATE: I have added a 4th and 5th case which use the generateMetadata function and the metadata property respectively in the page to add the metadata. Current behavior: the tags are deduplicated and then put in the body. The biggest change is that using either of these does deduplication of the tags but then the tags go, consistently, to either the head or the body depending on whether there is a <Suspense> or not (with <Suspense> is goes in the body , without they go to the head).
Root Cause Found: RSC Flight Data vs SSR HTML
I've done much more testing and found the root cause of this issue.
The Problem
When serveStreamingMetadata is true (the default), metadata is sent via RSC Flight data instead of initial SSR HTML. React's hoisting only works on SSR HTML, not Flight data.
Evidence
I created multiple test scenarios (relative to my sample):
Test 1: Inline JSX metadata (bypasses Next.js API)
export default function Page() {
return <><title>Test</title><meta .../><div>Content</div></>
}
✅ Result: All tags in <head> (React server-side hoisting works)
Test 5/6: Next.js metadata property
export const metadata = { title: 'Test', description: '...' }
❌ Result: All tags in <body> (sent via Flight data, no hoisting)
Test 8: Promise + Suspense (plain JSX, no Next.js API)
const metadataPromise = new Promise(resolve =>
setTimeout(() => resolve(<title>Test</title>), 1000)
)
return <Suspense>{metadataPromise}</Suspense>
- curl: Tags in
<body>(server can't hoist delayed content) - Browser: Only
<title>hoisted,<meta>tags stay in body - Finding: React's client-side hoisting is incomplete
The Two Channels
Channel 1 - SSR HTML (actual HTML tags):
<head><title>Test</title></head>
- React's server-side hoisting works ✅
- What bots/crawlers see
- Used when content is available during initial render
Channel 2 - RSC Flight data (serialized in scripts):
<script>self.__next_f.push([1, "[[\"$\",\"title\",...]"])</script>
- For Suspense boundaries that resolve later
- Client reconstructs and patches into DOM
- React's hoisting doesn't work on this ❌
Why Metadata Goes Through Flight Data
When Suspense wraps metadata (metadata.tsx lines 163-166):
- Server starts streaming HTML
- Encounters Suspense with pending metadata promise
- Sends placeholder, continues streaming
- Later: Metadata resolves
- Sends via Flight data (Channel 2)
- Client patches it into body
- No hoisting happens ❌
The Fix
Remove the Suspense wrapper:
// Current (broken):
return (
<div hidden>
<MetadataBoundary>
<Suspense name="Next.Metadata"> // ← Remove
{pendingMetadataTags}
</Suspense> // ← Remove
</MetadataBoundary>
</div>
)
// Fixed:
return (
<MetadataBoundary>
{pendingMetadataTags} // Forces server to wait, uses SSR HTML
</MetadataBoundary>
)
Testing: I verified this fix works - all metadata renders in <head> correctly with all test scenarios.
Why This Works
Without Suspense:
- Server waits for metadata promise (even if instant)
- Metadata becomes part of initial SSR HTML (Channel 1)
- React's server-side hoisting moves tags to
<head>✅ - Proper HTML structure for bots, browsers, everyone
Trade-off: Response delayed by milliseconds, but:
- Metadata is invisible anyway
- For
export const metadata, it's instant (already synchronous) - For
async generateMetadata(), metadata must be in initial HTML for SEO - Correctness matters more than microseconds
Questions for Team
-
Why use RSC Flight for metadata?
- Metadata needs to be in initial HTML for SEO/bots
- Flight data is for dynamic component updates
- These seem incompatible?
-
Why is
serveStreamingMetadatatrue by default?- What's the performance benefit of streaming invisible metadata?
- Breaks functionality (SEO, social sharing, standards)
-
The TODO comment (lines 149-151) says:
// TODO: We shouldn't change what we render based on whether we are streaming or not. // If we aren't streaming we should just block the response until we have resolved the // metadata.Is this being worked on?
-
Would you accept a PR that:
- Removes the Suspense wrapper?
- Changes
serveStreamingMetadatadefault tofalse? - Or implements the TODO (always block for metadata)?
Impact
Affects all App Router apps using export const metadata in layout.tsx (the recommended pattern for site-wide defaults).
Breaks:
- SEO
- Social media sharing (Twitter, Facebook, LinkedIn, WhatsApp)
- HTML validation
- Document structure
Reproduction
https://github.com/robbchar/next-metadata-example
Layout metadata renders in <body>. With Suspense removed, it renders in <head>.
Happy to provide more details or submit a PR with the fix!
This is also happening with
htmlLimitedBots: /.*/
set in my next configuration file, which sounds strange as it should’ve completely disabled streaming metadata.
UPDATE: Looking at the code, I see that whether to stream metadata or not is always the product of a runtime evaluation: https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/streaming-metadata.ts#L16.
First of all, I don’t see why the absence of a user-agent header should make the logic fall back to the streaming option, while IMO it would make sense to opt for the “safest” option when in doubt, which is a blocking render where we avoid streaming stuff for a client which does not guarantee us to be able to handle it.
Also, as somebody running a Next.js website in a k8s cluster as a “dynamic” server delegating caching to a CDN put in front of it (I never cache anything at the application level, which to me has never made sense anyway, and I spit out as-static-as-possible payload cached by the CDN), this makes zero sense, as it’s gratuitously making Next.js uncacheable at a CDN level, since the server is changing its rendering behaviour depending on each request's headers.
I’m totally fine with this feature being opt-in, but htmlLimitedBots is not a solution for those of us who would happily opt-out of streaming behaviour completely.
I made more changes to metadata.tsx and updated the tests which all pass now.
same problem with Next 16.0.1 version
Edit by maintainer bot: Comment was automatically minimized because it was considered unhelpful. (If you think this was by mistake, let us know). Please only comment if it adds context to the issue. If you want to express that you have the same problem, use the upvote 👍 on the issue description or subscribe to the issue for updates. Thanks!
This is still happening with 16.0.2-canary.11 version
Edit by maintainer bot: Comment was automatically minimized because it was considered unhelpful. (If you think this was by mistake, let us know). Please only comment if it adds context to the issue. If you want to express that you have the same problem, use the upvote 👍 on the issue description or subscribe to the issue for updates. Thanks!
Just tried 16.0.7 version and the problem is still there...
Edit by maintainer bot: Comment was automatically minimized because it was considered unhelpful. (If you think this was by mistake, let us know). Please only comment if it adds context to the issue. If you want to express that you have the same problem, use the upvote 👍 on the issue description or subscribe to the issue for updates. Thanks!
Just tried
16.0.7version and the problem is still there...Edit by maintainer bot: Comment was automatically minimized because it was considered unhelpful. (If you think this was by mistake, let us know). Please only comment if it adds context to the issue. If you want to express that you have the same problem, use the upvote 👍 on the issue description or subscribe to the issue for updates. Thanks!
It was resolved for me by using the latest version
Hello, I see that tests in pull request are still failing. Are there any other workarounds or do we have to wait until it's fixed and merged?