DEV Community

Cover image for Next.js Streaming Metadata: Why Your `<head>` Looks Incomplete
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

Next.js Streaming Metadata: Why Your `<head>` Looks Incomplete

You add a generateMetadata function to a product page so the <title> and Open Graph image come from your CMS instead of a hardcoded string:

// app/products/[slug]/page.tsx
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const product = await getProductFromCMS(slug); // ~2s on a slow day
  return {
    title: product.name,
    openGraph: { images: [product.heroImage] },
  };
}
Enter fullscreen mode Exit fullscreen mode

It works. You click the link in Chrome, the tab title updates, the page looks exactly right. You ship it.

Two days later, someone pastes the link in Slack and the unfurled card shows your site's generic fallback title and no image — as if generateMetadata never ran. You paste the same URL into curl to debug it, and the very first chunk of HTML that comes back really does have the fallback <title>, not the product name. Your function isn't broken. You've just met streaming metadata — a real behavior difference between how Next.js answers a browser and how it answers everything else, and it has been the stable default since Next.js 15.2.

What you'll learn

By the end of this article you'll be able to:

  • Explain why a slow generateMetadata can look instant in a browser tab but still show stale tags to a link-preview bot or a bare curl
  • Describe exactly which bytes Next.js sends first, and when the real <title> and Open Graph tags actually land
  • Use htmlLimitedBots to decide, on purpose, which clients get the blocking contract instead of the streaming one
  • Extend parent metadata with await parent instead of refetching data a layout above you already resolved
  • Avoid the one mistake that turns this feature into a real production liability: slow work inside generateMetadata, which still blocks every bot on your list

Who this is for

You've shipped at least one generateMetadata function — a dynamic <title>, an Open Graph image, something that depends on route params or a fetch. You don't need prior exposure to streaming or Suspense; this article builds the model from the response bytes up.

This is written against Next.js 16.3 (verified via npm's latest dist-tag, currently 16.3.8, and the framework's own documentation, October 2026). Streaming metadata shipped as experimental in Next.js 15 and has been stable since 15.2; nothing about the behavior described here is new to 16, but it's still the single most common surprise in generateMetadata issues on GitHub, and 16's default htmlLimitedBots list is the one you'll actually be configuring today.

Table of contents

The problem: one fetch, two different first impressions

Before streaming metadata existed, the rule was simple and expensive: Next.js would not send any HTML until generateMetadata fully resolved. A 2-second CMS call meant a 2-second blank screen for every visitor, every time, because the framework had no way to show a page without first knowing what goes in its <head>.

Next.js 15.2 changed the contract for ordinary visitors. Now, for a dynamically rendered route, the initial HTML can stream to the browser before generateMetadata resolves — using placeholder or previously-resolved values where needed — and the real tags arrive moments later, once the promise settles. A human never waits on your CMS call to see the page.

But a <title> that arrives after the first paint is invisible to anything that doesn't execute JavaScript and re-read the DOM: a link-unfurling bot, an RSS reader, a quick curl, most SEO crawlers. For those clients, "the tags arrive eventually" isn't good enough — they read once and move on. So Next.js detects them by User-Agent against a built-in (and configurable) list called htmlLimitedBots, and for anyone on that list it reverts to the old, blocking contract: wait for generateMetadata, then send one complete document.

That's the whole story behind the Slack bug above. Slack's unfurler matched the bot list and got the honest, complete, slow response. Your own curl test — run without a recognizable bot User-Agent — got the fast, streaming response, and happened to catch it before the real tags landed. Nothing was broken; two different clients were served two different, equally-intentional contracts.

The mental model: two contracts, one function

The mental model: generateMetadata always runs the same way — same code, same params, same parent metadata — but Next.js wraps its result differently depending on who's asking:

  • A regular client (browsers, most tools) gets the streaming contract. The initial HTML ships immediately with whatever metadata is already known — static values from export const metadata, and anything resolved by parent segments — plus a placeholder for what's still pending. When generateMetadata resolves, Next.js streams the real tags down the same connection and appends them to the end of the document — the same out-of-order delivery trick React already uses to fill in a <Suspense> boundary's content after the fact. Because <title>, <meta>, and <link> are tags React treats as hoistable, the browser moves them into the document's actual <head> the moment they arrive, so the live DOM ends up looking correct even though the bytes landed after <body>.
  • A client matched against htmlLimitedBots gets the blocking contract. Next.js withholds the response until generateMetadata (and anything it awaits) finishes, then sends one document with a complete, final <head> from the first byte. This is deliberately the old behavior, kept alive on purpose for exactly the clients that need it.

Neither contract is a bug version of the other — they're both correct, for different audiences. The mistake is assuming there's only one.

Stage 1: what a streaming client actually receives

Take a route with a 2-second metadata fetch and open it in a real browser with the network panel recording document load. You'll see the initial HTML response arrive almost instantly, containing:

<head>
  <!-- whatever resolved synchronously or came from a parent layout -->
  <meta charset="utf-8" />
  <!-- nothing reserved here for the pending tags — they arrive later, appended after <body> -->
</head>
<body><!-- your page's visible content, already rendering --></body>
Enter fullscreen mode Exit fullscreen mode

A moment later — once getProductFromCMS resolves — Next.js streams the real <title> and openGraph tags over the same connection and appends them near the end of <body>. Because those are tags React hoists automatically, the browser relocates them into the document's real <head> as soon as they arrive. View the rendered DOM in DevTools after the page settles and it looks completely normal: a full, correct <head>. View the raw network response (or a tool that reads bytes instead of executing scripts) and the tags you expected simply aren't there yet — and when they do arrive, they show up appended after the body content, not spliced into the original <head> block.

Key concept: streaming metadata doesn't make generateMetadata run faster — it makes the page stop waiting for it. The fetch still takes 2 seconds; what changes is who's forced to sit through them.

Stage 2: what a blocked client actually receives

Now fetch the same URL with a User-Agent on the default bot list — Slackbot, Twitterbot, facebookexternalhit, and Bingbot are the kind of names it covers out of the box, along with Google's non-rendering crawlers like AdsBot-Google and Mediapartners-Google (the mainline Googlebot executes JavaScript and reads the full DOM, so Next.js explicitly verifies it gets a correct result from the streaming contract instead — it isn't one of the blocked clients):

curl -A "Slackbot" https://example.com/products/wireless-mouse
Enter fullscreen mode Exit fullscreen mode

This request hangs for roughly 2 seconds — the full CMS fetch — and then returns one complete HTML document with the real product title and Open Graph image already in <head>. There is no placeholder, no follow-up script: this client only ever sees the finished page.

Key concept: the blocking contract isn't a fallback or a degraded mode — it's the one guarantee these clients actually need. A link-preview bot that reads one response and never runs JavaScript would never see a streamed-in tag; blocking is the only way to get it a correct result at all.

Stage 3: deciding who gets blocked, with htmlLimitedBots

The default list covers the obvious cases — major search crawlers and the social platforms' unfurlers — but it can't know about an internal tool, a less common regional crawler, or your own link-preview microservice. Configure it explicitly in next.config.ts:

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  htmlLimitedBots: /Slackbot|Twitterbot|facebookexternalhit|MyInternalLinkBot/i,
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

Setting htmlLimitedBots replaces the built-in list rather than extending it, so if you only want to add one bot to Next's defaults, you need to also include the ones you still want covered. Treat it as "here is my complete list," not "here is one more entry."

Key concept: this isn't a performance knob — it's a correctness decision about who gets the blocking guarantee. Add a client here only if it genuinely can't handle a streamed-in tag; adding more than necessary just reintroduces the slow blank-page problem you got streaming to avoid.

Stage 4: extending metadata instead of refetching it

generateMetadata's second argument, parent, is a promise of everything already resolved by segments above the current one in the tree. Reach for it instead of re-fetching data a layout already fetched:

// app/products/[slug]/page.tsx
import type { ResolvingMetadata } from 'next';

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> },
  parent: ResolvingMetadata,
) {
  const { slug } = await params;
  const product = await getProductFromCMS(slug);
  const previousImages = (await parent).openGraph?.images ?? [];

  return {
    title: product.name,
    openGraph: {
      images: [product.heroImage, ...previousImages],
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Awaiting parent doesn't trigger a second network call — it resolves to the same metadata object the parent layout's own generateMetadata already produced, merged according to Next's normal child-overrides-parent rules.

Key concept: parent composes metadata down the tree the same way props compose components. A layout that fetches an organization's default share image once shouldn't make every page beneath it fetch it again just to append to it.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

Stage 5: when there's nothing to stream at all

Streaming only matters for a dynamically rendered route with a generateMetadata that depends on something not known at build time. If a route is statically rendered — no runtime params, every fetch inside generateMetadata cacheable — the entire <head> resolves once, at build time, and ships as part of the single prerendered document for every visitor and every bot alike. There's no placeholder and no follow-up script, because there's nothing left pending by the time anyone requests the page.

This is the same static/dynamic split the series covered from the application-caching side in Next.js Cache Components Explained — a route whose data layer is fully cached or static also gets a fully resolved <head> for free, with nothing to stream. Streaming metadata only earns its keep on the routes that are genuinely dynamic, which is also where Route Handlers' caching defaults matter most. Neither article is required to follow this one, but the two caching models rhyme.

Edge cases and gotchas

  • A slow generateMetadata still fully blocks every client on your htmlLimitedBots list. Streaming protects browsers, not bots. If your CMS call regresses from 200ms to 4 seconds, every listed crawler waits the full 4 seconds. Cache that fetch the same way you'd cache any other request-blocking data source.
  • generateMetadata that itself reads cookies() or headers() becomes request-specific, forcing the route dynamic (or requiring its own <Suspense> placement under Cache Components) regardless of streaming metadata — the two behaviors are independent, and you can hit both at once.
  • A client not on the bot list but that also doesn't execute JavaScript — a naive scraper, an old integration — sees whatever was in the initial response the moment it read it, which may be the placeholder. Add it to htmlLimitedBots if you control it, or have it execute JS like a browser would.
  • "View page source" in some browsers shows the pre-hydration snapshot, not the live DOM — it can look like the bot-blocked bug even when the browser itself renders the final tags correctly. Verify with DevTools' Elements panel or a real unfurl test, not raw view-source.

Best practices

  • Cache the data generateMetadata depends on. It's on the hot path for every blocked bot, so treat its latency as a production SLA, not an afterthought.
  • Keep the default htmlLimitedBots list unless you have a specific reason to change it. It already covers the clients most teams care about; a narrower custom list is easy to under-specify.
  • Test real unfurl surfaces, not just curl with a guessed User-Agent — Slack, Discord, and X's own debugging tools will show you the response their actual crawler gets.
  • Extend parent instead of refetching anything a layout above the current segment already resolved.
  • Don't read runtime APIs inside generateMetadata unless the metadata genuinely is per-request — it couples this function's cost to the same dynamic-rendering rules as the rest of the route.

FAQ

Why does my page look correct in Chrome but show the wrong title when shared in Slack?

Chrome is a streaming client — it renders the placeholder first, then updates to the real tags once generateMetadata resolves, within the same page load, so you never notice two states. Slack's unfurler is on the htmlLimitedBots list and should get the fully-resolved document; if it's showing stale data instead, the usual cause is the unfurler caching its own previous fetch of your URL, not a Next.js bug. Force a fresh unfurl with your platform's cache-busting tool before assuming the response is wrong.

Can I turn off streaming metadata entirely?

Yes, by setting htmlLimitedBots to a pattern that matches everyone (/.*/), though that reintroduces the original cost: every visitor waits for generateMetadata before seeing anything. It's rarely the right trade — add specific clients to the list instead of blocking universally.

Is streaming metadata the same thing as Partial Prerendering or Cache Components?

Related, not identical. Cache Components and Partial Prerendering govern which parts of the page body are static, cached, or streamed. Streaming metadata is the same idea applied specifically to the document <head>, and it ships independently — you get it on a dynamic route whether or not you've opted into cacheComponents.

Does generateMetadata run before or after my page component?

Next.js runs generateMetadata and your page's rendering work in parallel where possible, not strictly sequentially — the streaming behavior exists precisely so the page's visible content doesn't have to wait in line behind the metadata fetch.

Does this affect static routes at all?

No. A fully static route resolves its entire <head> at build time, so every visitor — human or bot — gets the same single, complete document. Streaming only has something to do on a dynamically rendered route.

Cheat sheet

Situation What happens Configure with
Regular browser, dynamic route Initial HTML streams immediately; real tags arrive once generateMetadata resolves Default behavior since Next.js 15.2
Client matched by htmlLimitedBots, dynamic route Response blocks until generateMetadata resolves; one complete document sent htmlLimitedBots in next.config.ts
Any client, static route <head> resolved once at build time; nothing to stream N/A — determined by static/dynamic rendering
Extending a parent's metadata await parent inside generateMetadata, merge rather than refetch parent: ResolvingMetadata second argument
generateMetadata reads cookies()/headers() Becomes request-specific; subject to the same dynamic-rendering rules as the rest of the route N/A — same rules as any runtime API read

🧠 Test yourself

Think it clicked? Take the 7-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

Key takeaways

  • Next.js answers a browser and a bot differently on purpose: browsers stream past a slow generateMetadata, while clients matched by htmlLimitedBots wait for a complete, final <head>.
  • A bug report that only reproduces in a link-preview tool or curl, never in a real browser, is the signature of this exact feature — check which contract the failing client actually got before assuming the metadata is broken.
  • The blocking contract means generateMetadata's latency is still a real cost for every bot on your list — streaming hides it from humans, it doesn't delete it.
  • await parent composes metadata down the route tree without a second fetch; reach for it before duplicating a parent layout's data call.
  • None of this applies to a fully static route — streaming only has a job where the page is genuinely dynamic.

That Slack unfurl showing the fallback title from the opening story turned out to have nothing to do with your code at all — Slack's own cache had the old response, and re-sharing the link after busting it pulled the real, complete <head> your generateMetadata had been producing the whole time. The fix wasn't in generateMetadata. It was knowing which of the two contracts you were even looking at.

📚 Read next


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (0)