next.js icon indicating copy to clipboard operation
next.js copied to clipboard

[Bug] Static metadata (export const metadata) rendered in <body> instead of <head> in 15.5.4

Open fabiotheo opened this issue 2 months ago • 12 comments

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>

fabiotheo avatar Oct 10 '25 15:10 fabiotheo

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

parkerhutchinson avatar Oct 10 '25 23:10 parkerhutchinson

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!

KimCookieYa avatar Oct 13 '25 04:10 KimCookieYa

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.

minhtrung0110 avatar Oct 13 '25 04:10 minhtrung0110

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

  1. 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
  2. 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?

  3. 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!

robbchar avatar Oct 13 '25 18:10 robbchar

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).

robbchar avatar Oct 13 '25 22:10 robbchar

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):

  1. Server starts streaming HTML
  2. Encounters Suspense with pending metadata promise
  3. Sends placeholder, continues streaming
  4. Later: Metadata resolves
  5. Sends via Flight data (Channel 2)
  6. Client patches it into body
  7. 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

  1. 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?
  2. Why is serveStreamingMetadata true by default?

    • What's the performance benefit of streaming invisible metadata?
    • Breaks functionality (SEO, social sharing, standards)
  3. 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?

  4. Would you accept a PR that:

    • Removes the Suspense wrapper?
    • Changes serveStreamingMetadata default to false?
    • 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!

robbchar avatar Oct 14 '25 23:10 robbchar

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.

hood avatar Oct 16 '25 08:10 hood

I made more changes to metadata.tsx and updated the tests which all pass now.

robbchar avatar Oct 20 '25 20:10 robbchar

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!

UsamaAshraf82 avatar Nov 08 '25 04:11 UsamaAshraf82

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!

MohammadShehadeh avatar Nov 09 '25 14:11 MohammadShehadeh

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!

Bewenben avatar Dec 08 '25 13:12 Bewenben

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!

It was resolved for me by using the latest version

MohammadShehadeh avatar Dec 08 '25 16:12 MohammadShehadeh

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?

ivmilicevic avatar Dec 16 '25 14:12 ivmilicevic