Next.js Meta Tags in the Body Instead of the Head: What Actually Breaks
You ship a Next.js App Router site, then do the thing everyone does before they go to bed: view-source on a blog post. And there it is. <title> and every og: tag sitting at the bottom of <body>, after the content, nowhere near <head>. Your first thought is that the metadata is broken. Your second thought is that Google has been indexing untitled pages for a month.
Somebody filed this against Next.js. It was closed as Not Planned, which is the single most alarming thing a maintainer can write on your bug. So here is the part that report does not tell you: this is documented, intentional behaviour called streaming metadata, shipped in Next.js 15.2, and Vercel closed the issue because there is nothing to fix.
That is not the same as "ignore it". There are real cases where this hurts you, and one config line that turns it off. But you need to know which case you are in before you change anything, because turning it off costs you time to first byte on every single request.
Next.js streams metadata for dynamically rendered pages so the UI can flush before generateMetadata resolves. The tags land in <body> in the raw HTML and get hoisted into <head> in the live DOM.
- Crawlers that execute JavaScript, Googlebot included, see the hoisted tags and are unaffected.
- Crawlers that only parse raw HTML get a different response. Next.js detects them by User-Agent and blocks on metadata for them, so their HTML has the tags in
<head>.facebookexternalhit,Twitterbot,Slackbot,LinkedInBot,WhatsApp,DiscordbotandBingbotare in the default list. - Test with
curl -A, not with your browser. Testing without a crawler User-Agent tells you nothing about what crawlers get. - To disable streaming metadata entirely, set
htmlLimitedBots: /.*/innext.config. Do this only if you have a specific crawler that is not on the list. - Prerendered pages never stream metadata at all. If your route is static, this does not apply to you.
What you are actually looking at
View-source on an affected route gives you roughly this shape:
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="stylesheet" href="/"/>
</head>
<body>
<div>... the whole page ...</div>
<title>How to reconcile an M-Pesa statement</title>
<meta name="description" content="..."/>
<meta property="og:title" content="How to reconcile an M-Pesa statement"/>
</body>
Two meta tags made it into <head>, the charset and the viewport, because Next.js always emits those with the shell. Everything from your metadata export or generateMetadata arrived later.
Now open devtools and look at the Elements panel. The tags are in <head>. That is not you misreading it, and it is not a browser quirk. React 19 hoists document metadata elements into the head as it processes them, so the raw bytes and the live DOM genuinely disagree.
Why this happens
Before 15.2, an async generateMetadata blocked the entire response. The server could not send a byte of HTML until your metadata data fetch finished, because <head> comes first in the document and you cannot stream past it without knowing what goes in it. If your CMS took 400ms, every visitor waited 400ms staring at a white screen before the shell arrived.
Next.js 15.2 changed that. For dynamically rendered pages, metadata resolution is moved out of the critical path. The shell and the UI flush immediately, and when generateMetadata resolves the tags are appended after the body content. The Next.js docs state it plainly: "When generateMetadata resolves, the resulting metadata tags are appended to the <body> tag."
This is safe for the browser because of the HTML parser and React's hoisting. It is not safe for a crawler that reads bytes and stops. So Next.js does something that surprises people: it serves two different documents from the same URL depending on the User-Agent.
The default list lives in html-bots.ts in the Next.js source and is matched case-insensitively. It covers the ones that matter: all Google crawlers with a -Google suffix or Google- prefix, Bingbot, BingPreview, applebot, facebookexternalhit, facebookcatalog, Twitterbot, LinkedInBot, Slackbot, Discordbot, WhatsApp, SkypeUriPreview, redditbot, DuckDuckBot, yandex, baiduspider and a few more.
Notice who is not on it: plain Googlebot. That is deliberate. Googlebot runs JavaScript and reads the rendered DOM, so it sees the hoisted tags. The docs say Vercel verified this. If your entire worry was organic Google traffic, that worry is now closed.
Is my route even affected?
Streaming metadata only applies to dynamically rendered routes. A prerendered page has its metadata resolved at build time and baked into <head> like it always was. So the first question is not "how do I fix this", it is "is this route static or dynamic".
npm run build
Read the route table in the output and the legend under it:
Route (app) Size First Load JS
┌ ○ / 1.2 kB 98 kB
├ ○ /about 0.9 kB 97 kB
└ ƒ /blog/[slug] 2.1 kB 103 kB
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
| Route type | Metadata behaviour | Do you need to act? |
|---|---|---|
Static (○) or prerendered with generateStaticParams | Resolved at build, written into <head> | No. Streaming does not apply. |
Dynamic (ƒ), crawler on the default list | Blocking, written into <head> | No. Already handled. |
Dynamic (ƒ), a crawler not on the list | Streamed, appended to <body> | Yes. See Step 3. |
Anything in next dev | Streamed, because dev renders dynamically | No. Dev is not evidence of anything here. |
That last row deserves emphasis, because it is how most people arrive at this problem in the first place, including the original bug report. Development mode renders everything on demand. Drawing a production SEO conclusion from next dev output is the same mistake as judging production behaviour from a laptop generally.
How to check it properly
Step 1: Look at the raw bytes as a browser
curl -s https://example.com/blog/my-post \
| tr '>' '>\n' | grep -n -E 'og:title|</head|<body'
Splitting on > gives you line numbers you can compare. If og:title reports a higher line number than </head, the tag is after the head closed. On a dynamic route this is expected, and it is what you saw in view-source.
Step 2: Repeat the request as a crawler
This is the step nobody in the GitHub thread ran, and it is the whole answer:
curl -s -A "facebookexternalhit/1.1" https://example.com/blog/my-post \
| tr '>' '>\n' | grep -n -E 'og:title|</head|<body'
Now og:title should report a lower line number than </head. Same URL, same deploy, different document. If that is what you see, you have nothing to fix and you can stop here.
Step 3: Add crawlers the default list does not know about
The default regex is good but not universal. If you care about a preview scraper that is not in it, a monitoring tool, or an in-house crawler, extend the list yourself. The config takes a regular expression and replaces the default, so you must include the defaults you still want:
import type { NextConfig } from 'next'
const config: NextConfig = {
htmlLimitedBots:
/facebookexternalhit|Twitterbot|Slackbot|LinkedInBot|WhatsApp|Discordbot|Bingbot|OurInternalCrawler/,
}
export default config
Be honest with yourself about whether you actually need this. Every UA you add pays the full metadata resolution time on every request it makes.
Step 4: Turn streaming metadata off entirely, if you must
import type { NextConfig } from 'next'
const config: NextConfig = {
htmlLimitedBots: /.*/,
}
export default config
A regex that matches every User-Agent means every request gets blocking metadata. This restores pre-15.2 behaviour exactly, including the pre-15.2 latency. The Next.js docs are direct about the trade: overriding this could lead to longer response times, and the default is meant to be enough.
Step 5: The better fix is usually to make the route static
Most routes that stream metadata are dynamic for a reason unrelated to metadata: an uncached fetch, a cookies() call in a layout, a header read buried in an analytics wrapper. Find that and remove it and the route prerenders, which fixes metadata placement, TTFB and cache hit rate in one move. Use generateStaticParams for content routes with a known set of slugs. A blog with 200 posts has no business being dynamically rendered.
Verification
Run both requests against production and diff them. If the two outputs are byte-identical, your branch is not happening and something in front of Next.js is interfering:
curl -s https://example.com/blog/my-post > /tmp/browser.html
curl -s -A "Twitterbot/1.0" https://example.com/blog/my-post > /tmp/bot.html
diff <(grep -o 'og:title' /tmp/browser.html) <(grep -o 'og:title' /tmp/bot.html)
grep -c 'og:title' /tmp/bot.html
Then check the two things that actually matter to a human. Paste the URL into a Slack message and see whether the unfurl shows your title and image. Run the URL through Google Search Console's URL Inspection tool and look at the rendered HTML it reports, not the raw HTML. Those two checks tell you more than any amount of view-source.
What people get wrong
| Advice you will find | Why it is wrong |
|---|---|
| "Downgrade to 15.1 to get metadata back in the head" | Trading security and bug fixes for a behaviour you can toggle with one config line. And it does not survive your next upgrade. |
"Use next/head in a client component" | next/head is a Pages Router API. It has no effect in the App Router. You will ship it, see nothing change, and lose two hours. |
| "Add react-helmet" | Injects tags client-side only. HTML-limited crawlers never run it, so you have made the exact problem you were worried about, on purpose. |
| "Remove all Suspense boundaries above the page" | Misreads the mechanism. Streaming metadata is triggered by the route being dynamically rendered, not by your Suspense layout. Deleting boundaries costs you streaming UI and changes nothing here. |
| "Google will not index it" | Googlebot executes JavaScript and reads the rendered DOM. It is also not on the HTML-limited list precisely because it does not need to be. |
"I saw it in next dev, so it is broken" | Dev renders every route dynamically. Reproduce against next build && next start before you conclude anything. |
When it is still broken
- Check your cache and CDN. Next.js branches on User-Agent, so any cache in front of it that keys purely on URL can serve a browser-shaped response to a crawler. If you run your own reverse proxy, make sure it is not caching HTML responses across different User-Agents. This is the failure mode that looks exactly like a Next.js bug and is not one, and it is worth reviewing alongside the rest of your proxy configuration.
- Confirm the tags exist at all. Missing is not the same as misplaced.
grep -c 'og:title'returning0in the crawler response meansgenerateMetadatathrew, returned an empty object, or was overridden by a child segment. Metadata merges shallowly from root layout down, so a child settingopenGraphreplaces the parent's entireopenGraphblock, not just the fields it names. - Check
metadataBase. Relative OG image paths without ametadataBasein the root layout produce URLs a scraper cannot fetch. The tag is present, the image is broken, and the unfurl looks empty for a completely different reason. - Look at proxy and middleware rewrites. Anything that strips or normalises the User-Agent header before it reaches Next.js will collapse both code paths into one. Log the incoming UA at the edge of your app and confirm the crawler's real string is arriving.
The general lesson is worth keeping. When a maintainer closes something as Not Planned, that is sometimes neglect and sometimes an answer. Read the docs for the feature before you read the issue thread, because the thread is written by people who were surprised and the docs are written by the people who did it on purpose.
Frequently asked questions
- Why are my Next.js meta tags rendering inside body instead of head?
- Next.js 15.2 introduced streaming metadata. On dynamically rendered routes it flushes the page shell and UI immediately, then appends the tags produced by generateMetadata after the body content once they resolve. React hoists those tags into head in the live DOM, so devtools shows them in the right place even though view-source does not. It is documented behaviour, not a bug, which is why the GitHub issue about it was closed as Not Planned.
- Does streaming metadata hurt my Google rankings?
- No. Googlebot executes JavaScript and reads the rendered DOM, where the tags have been hoisted into head, and Vercel states it verified this. Googlebot is deliberately not on the HTML-limited bot list because it does not need blocking metadata. Social preview scrapers such as facebookexternalhit, Twitterbot, Slackbot and LinkedInBot are on that list, and Next.js already serves them a blocking response with the tags in head.
- How do I disable streaming metadata in Next.js?
- Set htmlLimitedBots to a regex that matches every User-Agent in your Next.js config: htmlLimitedBots: /.*/ in next.config.ts or next.config.js. Every request will then block until generateMetadata resolves and the tags will be written into head, exactly as they were before 15.2. The cost is that you also get pre-15.2 time to first byte on every request, so only do this if you have a specific crawler that the default list misses.
- How do I test whether a crawler sees my meta tags in the head?
- Request the page twice with curl, once normally and once with a crawler User-Agent, for example curl -s -A 'facebookexternalhit/1.1' https://example.com/page. Compare where og:title appears relative to the closing head tag in each response. Testing in a browser or in next dev proves nothing, because neither one takes the crawler code path.