[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"search-suggestions":44,"article-nextjs-weekly-streaming-metadata-head":93,"related-nextjs-weekly-streaming-metadata-head":491,"code:tsx:true:3gckbz":581,"code:html:true:1uotb57":582,"code:bash:true:geflqe":583,"code:ts:true:2bs3cy":584,"code:tsx:true:1jb92y5":585,"comments-01a0fc86-ee8a-74df-9c72-1cb57c0ba6cc":586},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,[45,49,53,57,61,65,69,73,77,81,85,89],{"slug":46,"name":47,"articles":48},"webdev","Webdev",126,{"slug":50,"name":51,"articles":52},"javascript","Javascript",104,{"slug":54,"name":55,"articles":56},"frontend","Frontend",80,{"slug":58,"name":59,"articles":60},"tutorial","Tutorial",49,{"slug":62,"name":63,"articles":64},"css","Css",40,{"slug":66,"name":67,"articles":68},"typescript","Typescript",18,{"slug":70,"name":71,"articles":72},"performance","Performance",16,{"slug":74,"name":75,"articles":76},"react","React",15,{"slug":78,"name":79,"articles":80},"browser","Browser",12,{"slug":82,"name":83,"articles":84},"node","Node",11,{"slug":86,"name":87,"articles":88},"html","Html",9,{"slug":90,"name":91,"articles":92},"accessibility","Accessibility",8,{"id":94,"slug":95,"title":96,"subtitle":97,"excerpt":98,"coverUrl":99,"locale":13,"readingMinutes":100,"publishedAt":101,"viewCount":102,"likeCount":19,"commentCount":19,"author":103,"vertical":108,"topic":109,"tags":112,"_count":119,"playground":121,"body":123,"bodyMd":450,"seo":451,"translationGroupId":453,"series":454,"podcastUrl":97,"verticalId":5,"thread":479,"assessments":481,"translations":487,"quiz":489},"01a0fc86-ee8a-74df-9c72-1cb57c0ba6cc","nextjs-weekly-streaming-metadata-head","Next.js Streaming Metadata: Why Your `\u003Chead>` Looks Incomplete",null,"Next.js 16 streaming metadata sends users a page before generateMetadata resolves, but blocks crawlers for a complete head. How it works, plus a cheat sheet.","\u002Fmedia\u002Fcovers\u002Fnextjs-weekly-streaming-metadata-head.png",14,"2026-10-06T06:10:31.468Z",37,{"id":104,"name":105,"username":106,"avatarUrl":97,"headline":107},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":110,"name":111},"nextjs","Nextjs",[113,114,117,118],{"slug":110,"name":111,"color":97},{"slug":115,"name":116,"color":97},"seo","Seo",{"slug":46,"name":47,"color":97},{"slug":58,"name":59,"color":97},{"assessments":120},1,{"slug":95,"title":122},"Next.js streaming metadata — interactive playground",{"blocks":124,"version":120},[125,129,135,138,141,146,149,158,161,164,167,170,184,187,190,193,196,199,202,205,210,213,216,219,223,226,229,232,235,240,243,246,250,253,258,261,264,267,270,274,277,280,283,286,289,292,295,299,302,305,308,315,318,326,329,332,335,338,341,344,347,351,354,357,360,363,391,394,397,400,403,406,409,417,420,423,426,432,435,438,441,444],{"id":126,"html":127,"type":128},"b1","\u003Cp>You add a \u003Ccode>generateMetadata\u003C\u002Fcode> function to a product page so the \u003Ccode>&lt;title&gt;\u003C\u002Fcode> and Open Graph image come from your CMS instead of a hardcoded string:\u003C\u002Fp>","paragraph",{"id":130,"code":131,"type":132,"language":133,"highlight":134},"b2","\u002F\u002F app\u002Fproducts\u002F[slug]\u002Fpage.tsx\nexport async function generateMetadata({ params }: { params: Promise\u003C{ slug: string }> }) {\n  const { slug } = await params;\n  const product = await getProductFromCMS(slug); \u002F\u002F ~2s on a slow day\n  return {\n    title: product.name,\n    openGraph: { images: [product.heroImage] },\n  };\n}","code","tsx",[],{"id":136,"html":137,"type":128},"b3","\u003Cp>It works. You click the link in Chrome, the tab title updates, the page looks exactly right. You ship it.\u003C\u002Fp>",{"id":139,"html":140,"type":128},"b4","\u003Cp>Two days later, someone pastes the link in Slack and the unfurled card shows your site&#39;s generic fallback title and no image — as if \u003Ccode>generateMetadata\u003C\u002Fcode> never ran. You paste the same URL into \u003Ccode>curl\u003C\u002Fcode> to debug it, and the very first chunk of HTML that comes back really does have the fallback \u003Ccode>&lt;title&gt;\u003C\u002Fcode>, not the product name. Your function isn&#39;t broken. You&#39;ve just met \u003Cstrong>streaming metadata\u003C\u002Fstrong> — 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.\u003C\u002Fp>",{"id":142,"html":143,"text":144,"type":145,"level":31},"b5","What you&#39;ll learn","What you'll learn","heading",{"id":147,"html":148,"type":128},"b6","\u003Cp>By the end of this article you&#39;ll be able to:\u003C\u002Fp>",{"id":150,"type":151,"items":152,"ordered":18},"b7","list",[153,154,155,156,157],"Explain why a slow \u003Ccode>generateMetadata\u003C\u002Fcode> can look instant in a browser tab but still show stale tags to a link-preview bot or a bare \u003Ccode>curl\u003C\u002Fcode>","Describe exactly which bytes Next.js sends first, and when the real \u003Ccode>&lt;title&gt;\u003C\u002Fcode> and Open Graph tags actually land","Use \u003Ccode>htmlLimitedBots\u003C\u002Fcode> to decide, on purpose, which clients get the blocking contract instead of the streaming one","Extend parent metadata with \u003Ccode>await parent\u003C\u002Fcode> 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 \u003Ccode>generateMetadata\u003C\u002Fcode>, which still blocks every bot on your list",{"id":159,"html":160,"text":160,"type":145,"level":31},"b8","Who this is for",{"id":162,"html":163,"type":128},"b9","\u003Cp>You&#39;ve shipped at least one \u003Ccode>generateMetadata\u003C\u002Fcode> function — a dynamic \u003Ccode>&lt;title&gt;\u003C\u002Fcode>, an Open Graph image, something that depends on route params or a fetch. You don&#39;t need prior exposure to streaming or Suspense; this article builds the model from the response bytes up.\u003C\u002Fp>",{"id":165,"html":166,"type":128},"b10","\u003Cp>This is written against \u003Cstrong>Next.js 16.3\u003C\u002Fstrong> (verified via npm&#39;s \u003Ccode>latest\u003C\u002Fcode> dist-tag, currently \u003Ccode>16.3.8\u003C\u002Fcode>, and the framework&#39;s own documentation, October 2026). Streaming metadata shipped as experimental in Next.js 15 and has been stable since \u003Cstrong>15.2\u003C\u002Fstrong>; nothing about the behavior described here is new to 16, but it&#39;s still the single most common surprise in \u003Ccode>generateMetadata\u003C\u002Fcode> issues on GitHub, and 16&#39;s default \u003Ccode>htmlLimitedBots\u003C\u002Fcode> list is the one you&#39;ll actually be configuring today.\u003C\u002Fp>",{"id":168,"html":169,"text":169,"type":145,"level":31},"b11","Table of contents",{"id":171,"type":151,"items":172,"ordered":18},"b12",[173,174,175,176,177,178,179,180,181,182,183],"\u003Ca href=\"#the-problem-one-fetch-two-different-first-impressions\">The problem: one fetch, two different first impressions\u003C\u002Fa>","\u003Ca href=\"#the-mental-model-two-contracts-one-function\">The mental model: two contracts, one function\u003C\u002Fa>","\u003Ca href=\"#stage-1-what-a-streaming-client-actually-receives\">Stage 1: what a streaming client actually receives\u003C\u002Fa>","\u003Ca href=\"#stage-2-what-a-blocked-client-actually-receives\">Stage 2: what a blocked client actually receives\u003C\u002Fa>","\u003Ca href=\"#stage-3-deciding-who-gets-blocked-with-htmllimitedbots\">Stage 3: deciding who gets blocked, with \u003Ccode>htmlLimitedBots\u003C\u002Fcode>\u003C\u002Fa>","\u003Ca href=\"#stage-4-extending-metadata-instead-of-refetching-it\">Stage 4: extending metadata instead of refetching it\u003C\u002Fa>","\u003Ca href=\"#stage-5-when-theres-nothing-to-stream-at-all\">Stage 5: when there&#39;s nothing to stream at all\u003C\u002Fa>","\u003Ca href=\"#edge-cases-and-gotchas\">Edge cases and gotchas\u003C\u002Fa>","\u003Ca href=\"#best-practices\">Best practices\u003C\u002Fa>","\u003Ca href=\"#faq\">FAQ\u003C\u002Fa>","\u003Ca href=\"#cheat-sheet\">Cheat sheet\u003C\u002Fa>",{"id":185,"html":186,"text":186,"type":145,"level":31},"b13","The problem: one fetch, two different first impressions",{"id":188,"html":189,"type":128},"b14","\u003Cp>Before streaming metadata existed, the rule was simple and expensive: Next.js would not send \u003Cstrong>any\u003C\u002Fstrong> HTML until \u003Ccode>generateMetadata\u003C\u002Fcode> 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 \u003Ccode>&lt;head&gt;\u003C\u002Fcode>.\u003C\u002Fp>",{"id":191,"html":192,"type":128},"b15","\u003Cp>Next.js 15.2 changed the contract for ordinary visitors. Now, for a dynamically rendered route, the initial HTML can stream to the browser \u003Cstrong>before\u003C\u002Fstrong> \u003Ccode>generateMetadata\u003C\u002Fcode> 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.\u003C\u002Fp>",{"id":194,"html":195,"type":128},"b16","\u003Cp>But a \u003Ccode>&lt;title&gt;\u003C\u002Fcode> that arrives after the first paint is invisible to anything that doesn&#39;t execute JavaScript and re-read the DOM: a link-unfurling bot, an RSS reader, a quick \u003Ccode>curl\u003C\u002Fcode>, most SEO crawlers. For those clients, &quot;the tags arrive eventually&quot; isn&#39;t good enough — they read once and move on. So Next.js detects them by \u003Ccode>User-Agent\u003C\u002Fcode> against a built-in (and configurable) list called \u003Ccode>htmlLimitedBots\u003C\u002Fcode>, and for anyone on that list it reverts to the old, blocking contract: wait for \u003Ccode>generateMetadata\u003C\u002Fcode>, then send one complete document.\u003C\u002Fp>",{"id":197,"html":198,"type":128},"b17","\u003Cp>That&#39;s the whole story behind the Slack bug above. Slack&#39;s unfurler matched the bot list and got the honest, complete, slow response. Your own \u003Ccode>curl\u003C\u002Fcode> test — run without a recognizable bot \u003Ccode>User-Agent\u003C\u002Fcode> — 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.\u003C\u002Fp>",{"id":200,"html":201,"text":201,"type":145,"level":31},"b18","The mental model: two contracts, one function",{"id":203,"html":204,"type":128},"b19","\u003Cp>\u003Cstrong>The mental model:\u003C\u002Fstrong> \u003Ccode>generateMetadata\u003C\u002Fcode> always runs the same way — same code, same \u003Ccode>params\u003C\u002Fcode>, same \u003Ccode>parent\u003C\u002Fcode> metadata — but Next.js wraps its result differently depending on who&#39;s asking:\u003C\u002Fp>",{"id":206,"type":151,"items":207,"ordered":18},"b20",[208,209],"\u003Cstrong>A regular client (browsers, most tools) gets the streaming contract.\u003C\u002Fstrong> The initial HTML ships immediately with whatever metadata is already known — static values from \u003Ccode>export const metadata\u003C\u002Fcode>, and anything resolved by parent segments — plus a placeholder for what&#39;s still pending. When \u003Ccode>generateMetadata\u003C\u002Fcode> 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 \u003Ccode>&lt;Suspense&gt;\u003C\u002Fcode> boundary&#39;s content after the fact. Because \u003Ccode>&lt;title&gt;\u003C\u002Fcode>, \u003Ccode>&lt;meta&gt;\u003C\u002Fcode>, and \u003Ccode>&lt;link&gt;\u003C\u002Fcode> are tags React treats as hoistable, the browser moves them into the document&#39;s actual \u003Ccode>&lt;head&gt;\u003C\u002Fcode> the moment they arrive, so the live DOM ends up looking correct even though the bytes landed after \u003Ccode>&lt;body&gt;\u003C\u002Fcode>.","\u003Cstrong>A client matched against \u003Ccode>htmlLimitedBots\u003C\u002Fcode> gets the blocking contract.\u003C\u002Fstrong> Next.js withholds the response until \u003Ccode>generateMetadata\u003C\u002Fcode> (and anything it awaits) finishes, then sends one document with a complete, final \u003Ccode>&lt;head&gt;\u003C\u002Fcode> from the first byte. This is deliberately the \u003Cem>old\u003C\u002Fem> behavior, kept alive on purpose for exactly the clients that need it.",{"id":211,"html":212,"type":128},"b21","\u003Cp>Neither contract is a bug version of the other — they&#39;re both correct, for different audiences. The mistake is assuming there&#39;s only one.\u003C\u002Fp>",{"id":214,"html":215,"text":215,"type":145,"level":31},"b22","Stage 1: what a streaming client actually receives",{"id":217,"html":218,"type":128},"b23","\u003Cp>Take a route with a 2-second metadata fetch and open it in a real browser with the network panel recording document load. You&#39;ll see the initial HTML response arrive almost instantly, containing:\u003C\u002Fp>",{"id":220,"code":221,"type":132,"language":86,"highlight":222},"b24","\u003Chead>\n  \u003C!-- whatever resolved synchronously or came from a parent layout -->\n  \u003Cmeta charset=\"utf-8\" \u002F>\n  \u003C!-- nothing reserved here for the pending tags — they arrive later, appended after \u003Cbody> -->\n\u003C\u002Fhead>\n\u003Cbody>\u003C!-- your page's visible content, already rendering -->\u003C\u002Fbody>",[],{"id":224,"html":225,"type":128},"b25","\u003Cp>A moment later — once \u003Ccode>getProductFromCMS\u003C\u002Fcode> resolves — Next.js streams the real \u003Ccode>&lt;title&gt;\u003C\u002Fcode> and \u003Ccode>openGraph\u003C\u002Fcode> tags over the same connection and appends them near the end of \u003Ccode>&lt;body&gt;\u003C\u002Fcode>. Because those are tags React hoists automatically, the browser relocates them into the document&#39;s real \u003Ccode>&lt;head&gt;\u003C\u002Fcode> as soon as they arrive. View the rendered DOM in DevTools after the page settles and it looks completely normal: a full, correct \u003Ccode>&lt;head&gt;\u003C\u002Fcode>. View the raw network response (or a tool that reads bytes instead of executing scripts) and the tags you expected simply aren&#39;t there yet — and when they do arrive, they show up appended after the body content, not spliced into the original \u003Ccode>&lt;head&gt;\u003C\u002Fcode> block.\u003C\u002Fp>",{"id":227,"html":228,"type":128},"b26","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> streaming metadata doesn&#39;t make \u003Ccode>generateMetadata\u003C\u002Fcode> run faster — it makes the \u003Cem>page\u003C\u002Fem> stop waiting for it. The fetch still takes 2 seconds; what changes is who&#39;s forced to sit through them.\u003C\u002Fp>",{"id":230,"html":231,"text":231,"type":145,"level":31},"b27","Stage 2: what a blocked client actually receives",{"id":233,"html":234,"type":128},"b28","\u003Cp>Now fetch the same URL with a \u003Ccode>User-Agent\u003C\u002Fcode> on the default bot list — \u003Ccode>Slackbot\u003C\u002Fcode>, \u003Ccode>Twitterbot\u003C\u002Fcode>, \u003Ccode>facebookexternalhit\u003C\u002Fcode>, and \u003Ccode>Bingbot\u003C\u002Fcode> are the kind of names it covers out of the box, along with Google&#39;s \u003Cem>non-rendering\u003C\u002Fem> crawlers like \u003Ccode>AdsBot-Google\u003C\u002Fcode> and \u003Ccode>Mediapartners-Google\u003C\u002Fcode> (the mainline \u003Ccode>Googlebot\u003C\u002Fcode> executes JavaScript and reads the full DOM, so Next.js explicitly verifies it gets a correct result from the \u003Cem>streaming\u003C\u002Fem> contract instead — it isn&#39;t one of the blocked clients):\u003C\u002Fp>",{"id":236,"code":237,"type":132,"language":238,"highlight":239},"b29","curl -A \"Slackbot\" https:\u002F\u002Fexample.com\u002Fproducts\u002Fwireless-mouse","bash",[],{"id":241,"html":242,"type":128},"b30","\u003Cp>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 \u003Ccode>&lt;head&gt;\u003C\u002Fcode>. There is no placeholder, no follow-up script: this client only ever sees the finished page.\u003C\u002Fp>",{"id":244,"html":245,"type":128},"b31","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> the blocking contract isn&#39;t a fallback or a degraded mode — it&#39;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.\u003C\u002Fp>",{"id":247,"html":248,"text":249,"type":145,"level":31},"b32","Stage 3: deciding who gets blocked, with \u003Ccode>htmlLimitedBots\u003C\u002Fcode>","Stage 3: deciding who gets blocked, with htmlLimitedBots",{"id":251,"html":252,"type":128},"b33","\u003Cp>The default list covers the obvious cases — major search crawlers and the social platforms&#39; unfurlers — but it can&#39;t know about an internal tool, a less common regional crawler, or your own link-preview microservice. Configure it explicitly in \u003Ccode>next.config.ts\u003C\u002Fcode>:\u003C\u002Fp>",{"id":254,"code":255,"type":132,"language":256,"highlight":257},"b34","\u002F\u002F next.config.ts\nimport type { NextConfig } from 'next';\n\nconst nextConfig: NextConfig = {\n  htmlLimitedBots: \u002FSlackbot|Twitterbot|facebookexternalhit|MyInternalLinkBot\u002Fi,\n};\n\nexport default nextConfig;","ts",[],{"id":259,"html":260,"type":128},"b35","\u003Cp>Setting \u003Ccode>htmlLimitedBots\u003C\u002Fcode> \u003Cstrong>replaces\u003C\u002Fstrong> the built-in list rather than extending it, so if you only want to add one bot to Next&#39;s defaults, you need to also include the ones you still want covered. Treat it as &quot;here is my complete list,&quot; not &quot;here is one more entry.&quot;\u003C\u002Fp>",{"id":262,"html":263,"type":128},"b36","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> this isn&#39;t a performance knob — it&#39;s a correctness decision about who gets the blocking guarantee. Add a client here only if it genuinely can&#39;t handle a streamed-in tag; adding more than necessary just reintroduces the slow blank-page problem you got streaming to avoid.\u003C\u002Fp>",{"id":265,"html":266,"text":266,"type":145,"level":31},"b37","Stage 4: extending metadata instead of refetching it",{"id":268,"html":269,"type":128},"b38","\u003Cp>\u003Ccode>generateMetadata\u003C\u002Fcode>&#39;s second argument, \u003Ccode>parent\u003C\u002Fcode>, 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:\u003C\u002Fp>",{"id":271,"code":272,"type":132,"language":133,"highlight":273},"b39","\u002F\u002F app\u002Fproducts\u002F[slug]\u002Fpage.tsx\nimport type { ResolvingMetadata } from 'next';\n\nexport async function generateMetadata(\n  { params }: { params: Promise\u003C{ slug: string }> },\n  parent: ResolvingMetadata,\n) {\n  const { slug } = await params;\n  const product = await getProductFromCMS(slug);\n  const previousImages = (await parent).openGraph?.images ?? [];\n\n  return {\n    title: product.name,\n    openGraph: {\n      images: [product.heroImage, ...previousImages],\n    },\n  };\n}",[],{"id":275,"html":276,"type":128},"b40","\u003Cp>Awaiting \u003Ccode>parent\u003C\u002Fcode> doesn&#39;t trigger a second network call — it resolves to the same metadata object the parent layout&#39;s own \u003Ccode>generateMetadata\u003C\u002Fcode> already produced, merged according to Next&#39;s normal child-overrides-parent rules.\u003C\u002Fp>",{"id":278,"html":279,"type":128},"b41","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> \u003Ccode>parent\u003C\u002Fcode> composes metadata down the tree the same way props compose components. A layout that fetches an organization&#39;s default share image once shouldn&#39;t make every page beneath it fetch it again just to append to it.\u003C\u002Fp>",{"id":281,"html":282,"type":128},"b42","\u003C!-- playground:start -->",{"id":284,"html":285,"text":285,"type":145,"level":31},"b43","🎮 Try it yourself",{"id":287,"html":288,"type":128},"b44","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-streaming-metadata-head\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":290,"html":291,"type":128},"b45","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":293,"html":294,"type":128},"b46","\u003C!-- playground:end -->",{"id":296,"html":297,"text":298,"type":145,"level":31},"b47","Stage 5: when there&#39;s nothing to stream at all","Stage 5: when there's nothing to stream at all",{"id":300,"html":301,"type":128},"b48","\u003Cp>Streaming only matters for a \u003Cstrong>dynamically rendered\u003C\u002Fstrong> route with a \u003Ccode>generateMetadata\u003C\u002Fcode> that depends on something not known at build time. If a route is statically rendered — no runtime params, every fetch inside \u003Ccode>generateMetadata\u003C\u002Fcode> cacheable — the entire \u003Ccode>&lt;head&gt;\u003C\u002Fcode> resolves once, at build time, and ships as part of the single prerendered document for every visitor and every bot alike. There&#39;s no placeholder and no follow-up script, because there&#39;s nothing left pending by the time anyone requests the page.\u003C\u002Fp>",{"id":303,"html":304,"type":128},"b49","\u003Cp>This is the same static\u002Fdynamic split the series covered from the application-caching side in \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fnextjs-cache-components-explained-with-cheat-sheet-55ob\">Next.js Cache Components Explained\u003C\u002Fa> — a route whose data layer is fully cached or static also gets a fully resolved \u003Ccode>&lt;head&gt;\u003C\u002Fcode> for free, with nothing to stream. Streaming metadata only earns its keep on the routes that are genuinely dynamic, which is also where \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fnextjs-route-handlers-get-stopped-caching-in-15-how-to-cache-in-16-56nb\">Route Handlers&#39; caching defaults\u003C\u002Fa> matter most. Neither article is required to follow this one, but the two caching models rhyme.\u003C\u002Fp>",{"id":306,"html":307,"text":307,"type":145,"level":31},"b50","Edge cases and gotchas",{"id":309,"type":151,"items":310,"ordered":18},"b51",[311,312,313,314],"\u003Cstrong>A slow \u003Ccode>generateMetadata\u003C\u002Fcode> still fully blocks every client on your \u003Ccode>htmlLimitedBots\u003C\u002Fcode> list.\u003C\u002Fstrong> 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&#39;d cache any other request-blocking data source.","\u003Cstrong>\u003Ccode>generateMetadata\u003C\u002Fcode> that itself reads \u003Ccode>cookies()\u003C\u002Fcode> or \u003Ccode>headers()\u003C\u002Fcode> becomes request-specific\u003C\u002Fstrong>, forcing the route dynamic (or requiring its own \u003Ccode>&lt;Suspense&gt;\u003C\u002Fcode> placement under Cache Components) regardless of streaming metadata — the two behaviors are independent, and you can hit both at once.","\u003Cstrong>A client not on the bot list but that also doesn&#39;t execute JavaScript\u003C\u002Fstrong> — 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 \u003Ccode>htmlLimitedBots\u003C\u002Fcode> if you control it, or have it execute JS like a browser would.","\u003Cstrong>&quot;View page source&quot; in some browsers shows the pre-hydration snapshot\u003C\u002Fstrong>, 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&#39; Elements panel or a real unfurl test, not raw view-source.",{"id":316,"html":317,"text":317,"type":145,"level":31},"b52","Best practices",{"id":319,"type":151,"items":320,"ordered":18},"b53",[321,322,323,324,325],"\u003Cstrong>Cache the data \u003Ccode>generateMetadata\u003C\u002Fcode> depends on.\u003C\u002Fstrong> It&#39;s on the hot path for every blocked bot, so treat its latency as a production SLA, not an afterthought.","\u003Cstrong>Keep the default \u003Ccode>htmlLimitedBots\u003C\u002Fcode> list unless you have a specific reason to change it.\u003C\u002Fstrong> It already covers the clients most teams care about; a narrower custom list is easy to under-specify.","\u003Cstrong>Test real unfurl surfaces\u003C\u002Fstrong>, not just \u003Ccode>curl\u003C\u002Fcode> with a guessed \u003Ccode>User-Agent\u003C\u002Fcode> — Slack, Discord, and X&#39;s own debugging tools will show you the response their actual crawler gets.","\u003Cstrong>Extend \u003Ccode>parent\u003C\u002Fcode> instead of refetching\u003C\u002Fstrong> anything a layout above the current segment already resolved.","\u003Cstrong>Don&#39;t read runtime APIs inside \u003Ccode>generateMetadata\u003C\u002Fcode> unless the metadata genuinely is per-request\u003C\u002Fstrong> — it couples this function&#39;s cost to the same dynamic-rendering rules as the rest of the route.",{"id":327,"html":328,"text":328,"type":145,"level":31},"b54","FAQ",{"id":330,"html":331,"text":331,"type":145,"level":43},"b55","Why does my page look correct in Chrome but show the wrong title when shared in Slack?",{"id":333,"html":334,"type":128},"b56","\u003Cp>Chrome is a streaming client — it renders the placeholder first, then updates to the real tags once \u003Ccode>generateMetadata\u003C\u002Fcode> resolves, within the same page load, so you never notice two states. Slack&#39;s unfurler is on the \u003Ccode>htmlLimitedBots\u003C\u002Fcode> list and should get the fully-resolved document; if it&#39;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&#39;s cache-busting tool before assuming the response is wrong.\u003C\u002Fp>",{"id":336,"html":337,"text":337,"type":145,"level":43},"b57","Can I turn off streaming metadata entirely?",{"id":339,"html":340,"type":128},"b58","\u003Cp>Yes, by setting \u003Ccode>htmlLimitedBots\u003C\u002Fcode> to a pattern that matches everyone (\u003Ccode>\u002F.*\u002F\u003C\u002Fcode>), though that reintroduces the original cost: every visitor waits for \u003Ccode>generateMetadata\u003C\u002Fcode> before seeing anything. It&#39;s rarely the right trade — add specific clients to the list instead of blocking universally.\u003C\u002Fp>",{"id":342,"html":343,"text":343,"type":145,"level":43},"b59","Is streaming metadata the same thing as Partial Prerendering or Cache Components?",{"id":345,"html":346,"type":128},"b60","\u003Cp>Related, not identical. Cache Components and Partial Prerendering govern which \u003Cem>parts of the page body\u003C\u002Fem> are static, cached, or streamed. Streaming metadata is the same idea applied specifically to the document \u003Ccode>&lt;head&gt;\u003C\u002Fcode>, and it ships independently — you get it on a dynamic route whether or not you&#39;ve opted into \u003Ccode>cacheComponents\u003C\u002Fcode>.\u003C\u002Fp>",{"id":348,"html":349,"text":350,"type":145,"level":43},"b61","Does \u003Ccode>generateMetadata\u003C\u002Fcode> run before or after my page component?","Does generateMetadata run before or after my page component?",{"id":352,"html":353,"type":128},"b62","\u003Cp>Next.js runs \u003Ccode>generateMetadata\u003C\u002Fcode> and your page&#39;s rendering work in parallel where possible, not strictly sequentially — the streaming behavior exists precisely so the page&#39;s visible content doesn&#39;t have to wait in line behind the metadata fetch.\u003C\u002Fp>",{"id":355,"html":356,"text":356,"type":145,"level":43},"b63","Does this affect static routes at all?",{"id":358,"html":359,"type":128},"b64","\u003Cp>No. A fully static route resolves its entire \u003Ccode>&lt;head&gt;\u003C\u002Fcode> 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.\u003C\u002Fp>",{"id":361,"html":362,"text":362,"type":145,"level":31},"b65","Cheat sheet",{"id":364,"head":365,"rows":369,"type":390},"b66",[366,367,368],"Situation","What happens","Configure with",[370,374,378,382,386],[371,372,373],"Regular browser, dynamic route","Initial HTML streams immediately; real tags arrive once \u003Ccode>generateMetadata\u003C\u002Fcode> resolves","Default behavior since Next.js 15.2",[375,376,377],"Client matched by \u003Ccode>htmlLimitedBots\u003C\u002Fcode>, dynamic route","Response blocks until \u003Ccode>generateMetadata\u003C\u002Fcode> resolves; one complete document sent","\u003Ccode>htmlLimitedBots\u003C\u002Fcode> in \u003Ccode>next.config.ts\u003C\u002Fcode>",[379,380,381],"Any client, static route","\u003Ccode>&lt;head&gt;\u003C\u002Fcode> resolved once at build time; nothing to stream","N\u002FA — determined by static\u002Fdynamic rendering",[383,384,385],"Extending a parent&#39;s metadata","\u003Ccode>await parent\u003C\u002Fcode> inside \u003Ccode>generateMetadata\u003C\u002Fcode>, merge rather than refetch","\u003Ccode>parent: ResolvingMetadata\u003C\u002Fcode> second argument",[387,388,389],"\u003Ccode>generateMetadata\u003C\u002Fcode> reads \u003Ccode>cookies()\u003C\u002Fcode>\u002F\u003Ccode>headers()\u003C\u002Fcode>","Becomes request-specific; subject to the same dynamic-rendering rules as the rest of the route","N\u002FA — same rules as any runtime API read","table",{"id":392,"html":393,"type":128},"b67","\u003C!-- quiz:start -->",{"id":395,"html":396,"text":396,"type":145,"level":31},"b68","🧠 Test yourself",{"id":398,"html":399,"type":128},"b69","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-streaming-metadata-head\u002Fquiz\">Take the 7-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":401,"html":402,"type":128},"b70","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":404,"html":405,"type":128},"b71","\u003C!-- quiz:end -->",{"id":407,"html":408,"text":408,"type":145,"level":31},"b72","Key takeaways",{"id":410,"type":151,"items":411,"ordered":18},"b73",[412,413,414,415,416],"Next.js answers a browser and a bot differently \u003Cstrong>on purpose\u003C\u002Fstrong>: browsers stream past a slow \u003Ccode>generateMetadata\u003C\u002Fcode>, while clients matched by \u003Ccode>htmlLimitedBots\u003C\u002Fcode> wait for a complete, final \u003Ccode>&lt;head&gt;\u003C\u002Fcode>.","A bug report that only reproduces in a link-preview tool or \u003Ccode>curl\u003C\u002Fcode>, 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 \u003Ccode>generateMetadata\u003C\u002Fcode>&#39;s latency is still a real cost for every bot on your list — streaming hides it from humans, it doesn&#39;t delete it.","\u003Ccode>await parent\u003C\u002Fcode> composes metadata down the route tree without a second fetch; reach for it before duplicating a parent layout&#39;s data call.","None of this applies to a fully static route — streaming only has a job where the page is genuinely dynamic.",{"id":418,"html":419,"type":128},"b74","\u003Cp>That Slack unfurl showing the fallback title from the opening story turned out to have nothing to do with your code at all — Slack&#39;s own cache had the old response, and re-sharing the link after busting it pulled the real, complete \u003Ccode>&lt;head&gt;\u003C\u002Fcode> your \u003Ccode>generateMetadata\u003C\u002Fcode> had been producing the whole time. The fix wasn&#39;t in \u003Ccode>generateMetadata\u003C\u002Fcode>. It was knowing which of the two contracts you were even looking at.\u003C\u002Fp>",{"id":421,"html":422,"type":128},"b75","\u003C!-- related:start -->",{"id":424,"html":425,"text":425,"type":145,"level":31},"b76","📚 Read next",{"id":427,"type":151,"items":428,"ordered":18},"b77",[429,430,431],"\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-route-handlers-caching-streaming\">Next.js Route Handlers: GET Stopped Caching in 15 — How to Cache in 16\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fserver-sent-events-eventsource-live-updates\">You Don&#39;t Need a WebSocket for That Live Feed\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffetch-already-streams-readablestream\">Your Fetch Already Streams. You&#39;re Buffering It Anyway.\u003C\u002Fa>",{"id":433,"html":434,"type":128},"b78","\u003C!-- related:end -->",{"id":436,"type":437},"b79","divider",{"id":439,"html":440,"type":128},"b80","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":442,"html":443,"type":128},"b81","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":445,"type":151,"items":446,"ordered":18},"b82",[447,448,449],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","You add a `generateMetadata` function to a product page so the `\u003Ctitle>` and Open Graph image come from your CMS instead of a hardcoded string:\n\n```tsx\n\u002F\u002F app\u002Fproducts\u002F[slug]\u002Fpage.tsx\nexport async function generateMetadata({ params }: { params: Promise\u003C{ slug: string }> }) {\n  const { slug } = await params;\n  const product = await getProductFromCMS(slug); \u002F\u002F ~2s on a slow day\n  return {\n    title: product.name,\n    openGraph: { images: [product.heroImage] },\n  };\n}\n```\n\nIt works. You click the link in Chrome, the tab title updates, the page looks exactly right. You ship it.\n\nTwo 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 `\u003Ctitle>`, 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.\n\n## What you'll learn\n\nBy the end of this article you'll be able to:\n\n- 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`\n- Describe exactly which bytes Next.js sends first, and when the real `\u003Ctitle>` and Open Graph tags actually land\n- Use `htmlLimitedBots` to decide, on purpose, which clients get the blocking contract instead of the streaming one\n- Extend parent metadata with `await parent` instead of refetching data a layout above you already resolved\n- 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\n\n## Who this is for\n\nYou've shipped at least one `generateMetadata` function — a dynamic `\u003Ctitle>`, 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.\n\nThis 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.\n\n## Table of contents\n\n- [The problem: one fetch, two different first impressions](#the-problem-one-fetch-two-different-first-impressions)\n- [The mental model: two contracts, one function](#the-mental-model-two-contracts-one-function)\n- [Stage 1: what a streaming client actually receives](#stage-1-what-a-streaming-client-actually-receives)\n- [Stage 2: what a blocked client actually receives](#stage-2-what-a-blocked-client-actually-receives)\n- [Stage 3: deciding who gets blocked, with `htmlLimitedBots`](#stage-3-deciding-who-gets-blocked-with-htmllimitedbots)\n- [Stage 4: extending metadata instead of refetching it](#stage-4-extending-metadata-instead-of-refetching-it)\n- [Stage 5: when there's nothing to stream at all](#stage-5-when-theres-nothing-to-stream-at-all)\n- [Edge cases and gotchas](#edge-cases-and-gotchas)\n- [Best practices](#best-practices)\n- [FAQ](#faq)\n- [Cheat sheet](#cheat-sheet)\n\n## The problem: one fetch, two different first impressions\n\nBefore 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 `\u003Chead>`.\n\nNext.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.\n\nBut a `\u003Ctitle>` 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.\n\nThat'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.\n\n## The mental model: two contracts, one function\n\n**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:\n\n- **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 `\u003CSuspense>` boundary's content after the fact. Because `\u003Ctitle>`, `\u003Cmeta>`, and `\u003Clink>` are tags React treats as hoistable, the browser moves them into the document's actual `\u003Chead>` the moment they arrive, so the live DOM ends up looking correct even though the bytes landed after `\u003Cbody>`.\n- **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 `\u003Chead>` from the first byte. This is deliberately the *old* behavior, kept alive on purpose for exactly the clients that need it.\n\nNeither contract is a bug version of the other — they're both correct, for different audiences. The mistake is assuming there's only one.\n\n## Stage 1: what a streaming client actually receives\n\nTake 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:\n\n```html\n\u003Chead>\n  \u003C!-- whatever resolved synchronously or came from a parent layout -->\n  \u003Cmeta charset=\"utf-8\" \u002F>\n  \u003C!-- nothing reserved here for the pending tags — they arrive later, appended after \u003Cbody> -->\n\u003C\u002Fhead>\n\u003Cbody>\u003C!-- your page's visible content, already rendering -->\u003C\u002Fbody>\n```\n\nA moment later — once `getProductFromCMS` resolves — Next.js streams the real `\u003Ctitle>` and `openGraph` tags over the same connection and appends them near the end of `\u003Cbody>`. Because those are tags React hoists automatically, the browser relocates them into the document's real `\u003Chead>` as soon as they arrive. View the rendered DOM in DevTools after the page settles and it looks completely normal: a full, correct `\u003Chead>`. 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 `\u003Chead>` block.\n\n**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.\n\n## Stage 2: what a blocked client actually receives\n\nNow 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):\n\n```bash\ncurl -A \"Slackbot\" https:\u002F\u002Fexample.com\u002Fproducts\u002Fwireless-mouse\n```\n\nThis 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 `\u003Chead>`. There is no placeholder, no follow-up script: this client only ever sees the finished page.\n\n**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.\n\n## Stage 3: deciding who gets blocked, with `htmlLimitedBots`\n\nThe 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`:\n\n```ts\n\u002F\u002F next.config.ts\nimport type { NextConfig } from 'next';\n\nconst nextConfig: NextConfig = {\n  htmlLimitedBots: \u002FSlackbot|Twitterbot|facebookexternalhit|MyInternalLinkBot\u002Fi,\n};\n\nexport default nextConfig;\n```\n\nSetting `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.\"\n\n**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.\n\n## Stage 4: extending metadata instead of refetching it\n\n`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:\n\n```tsx\n\u002F\u002F app\u002Fproducts\u002F[slug]\u002Fpage.tsx\nimport type { ResolvingMetadata } from 'next';\n\nexport async function generateMetadata(\n  { params }: { params: Promise\u003C{ slug: string }> },\n  parent: ResolvingMetadata,\n) {\n  const { slug } = await params;\n  const product = await getProductFromCMS(slug);\n  const previousImages = (await parent).openGraph?.images ?? [];\n\n  return {\n    title: product.name,\n    openGraph: {\n      images: [product.heroImage, ...previousImages],\n    },\n  };\n}\n```\n\nAwaiting `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.\n\n**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.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-streaming-metadata-head\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## Stage 5: when there's nothing to stream at all\n\nStreaming 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 `\u003Chead>` 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.\n\nThis is the same static\u002Fdynamic split the series covered from the application-caching side in [Next.js Cache Components Explained](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fnextjs-cache-components-explained-with-cheat-sheet-55ob) — a route whose data layer is fully cached or static also gets a fully resolved `\u003Chead>` 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](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fnextjs-route-handlers-get-stopped-caching-in-15-how-to-cache-in-16-56nb) matter most. Neither article is required to follow this one, but the two caching models rhyme.\n\n## Edge cases and gotchas\n\n- **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.\n- **`generateMetadata` that itself reads `cookies()` or `headers()` becomes request-specific**, forcing the route dynamic (or requiring its own `\u003CSuspense>` placement under Cache Components) regardless of streaming metadata — the two behaviors are independent, and you can hit both at once.\n- **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.\n- **\"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.\n\n## Best practices\n\n- **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.\n- **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.\n- **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.\n- **Extend `parent` instead of refetching** anything a layout above the current segment already resolved.\n- **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.\n\n## FAQ\n\n### Why does my page look correct in Chrome but show the wrong title when shared in Slack?\n\nChrome 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.\n\n### Can I turn off streaming metadata entirely?\n\nYes, by setting `htmlLimitedBots` to a pattern that matches everyone (`\u002F.*\u002F`), 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.\n\n### Is streaming metadata the same thing as Partial Prerendering or Cache Components?\n\nRelated, 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 `\u003Chead>`, and it ships independently — you get it on a dynamic route whether or not you've opted into `cacheComponents`.\n\n### Does `generateMetadata` run before or after my page component?\n\nNext.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.\n\n### Does this affect static routes at all?\n\nNo. A fully static route resolves its entire `\u003Chead>` 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.\n\n## Cheat sheet\n\n| Situation | What happens | Configure with |\n| --- | --- | --- |\n| Regular browser, dynamic route | Initial HTML streams immediately; real tags arrive once `generateMetadata` resolves | Default behavior since Next.js 15.2 |\n| Client matched by `htmlLimitedBots`, dynamic route | Response blocks until `generateMetadata` resolves; one complete document sent | `htmlLimitedBots` in `next.config.ts` |\n| Any client, static route | `\u003Chead>` resolved once at build time; nothing to stream | N\u002FA — determined by static\u002Fdynamic rendering |\n| Extending a parent's metadata | `await parent` inside `generateMetadata`, merge rather than refetch | `parent: ResolvingMetadata` second argument |\n| `generateMetadata` reads `cookies()`\u002F`headers()` | Becomes request-specific; subject to the same dynamic-rendering rules as the rest of the route | N\u002FA — same rules as any runtime API read |\n\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 7-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-streaming-metadata-head\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n## Key takeaways\n\n- 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 `\u003Chead>`.\n- 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.\n- 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.\n- `await parent` composes metadata down the route tree without a second fetch; reach for it before duplicating a parent layout's data call.\n- None of this applies to a fully static route — streaming only has a job where the page is genuinely dynamic.\n\nThat 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 `\u003Chead>` 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.\n\n\u003C!-- related:start -->\n\n## 📚 Read next\n\n- [Next.js Route Handlers: GET Stopped Caching in 15 — How to Cache in 16](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-route-handlers-caching-streaming)\n- [You Don't Need a WebSocket for That Live Feed](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fserver-sent-events-eventsource-live-updates)\n- [Your Fetch Already Streams. You're Buffering It Anyway.](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffetch-already-streams-readablestream)\n\n\u003C!-- related:end -->\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":96,"canonical":452,"description":98},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-streaming-metadata-head","01a0fc86-ee8a-74df-9c72-215c588c0188",{"name":455,"part":456,"total":456,"items":457},"Next.js Deep Dive",6,[458,462,466,470,474,478],{"slug":459,"title":460,"publishedAt":461,"readingMinutes":100},"nextjs-weekly-cache-components-explained","Next.js Cache Components Explained (with Cheat Sheet)","2026-09-01T11:24:15.087Z",{"slug":463,"title":464,"publishedAt":465,"readingMinutes":76},"nextjs-weekly-server-actions-mutations-security","Next.js Server Actions: Mutations & Security (Cheat Sheet)","2026-09-08T10:57:00.975Z",{"slug":467,"title":468,"publishedAt":469,"readingMinutes":100},"nextjs-weekly-parallel-intercepting-routes-modals","Next.js Parallel & Intercepting Routes: Modals Done Right","2026-09-15T17:50:47.218Z",{"slug":471,"title":472,"publishedAt":473,"readingMinutes":80},"nextjs-weekly-middleware-to-proxy-network-boundary","Next.js proxy.ts Explained (with Cheat Sheet)","2026-09-22T19:36:08.416Z",{"slug":475,"title":476,"publishedAt":477,"readingMinutes":76},"nextjs-weekly-route-handlers-caching-streaming","Next.js Route Handlers: GET Stopped Caching in 15 — How to Cache in 16","2026-09-29T09:16:08.176Z",{"slug":95,"title":96,"publishedAt":101,"readingMinutes":100},{"id":480,"locked":18},"01a0fc86-eeb5-75cf-9820-15d65ef9c085",[482],{"id":483,"slug":95,"title":484,"_count":485},"01a0fc86-eece-7754-bb4a-0cdf58a45e4c","Next.js Streaming Metadata",{"questions":486},7,[488],{"locale":13,"slug":95},{"id":483,"slug":95,"title":484,"_count":490,"questionCount":486},{"questions":486},{"items":492,"meta":580},[493,505,520,535,550,565],{"id":94,"slug":95,"title":96,"subtitle":97,"excerpt":98,"coverUrl":99,"locale":13,"readingMinutes":100,"publishedAt":101,"viewCount":494,"likeCount":19,"commentCount":19,"author":495,"vertical":496,"topic":497,"tags":498,"_count":503,"playground":504,"hasQuiz":17,"hasPlayground":17},38,{"id":104,"name":105,"username":106,"avatarUrl":97,"headline":107},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":110,"name":111},[499,500,501,502],{"slug":110,"name":111,"color":97},{"slug":115,"name":116,"color":97},{"slug":46,"name":47,"color":97},{"slug":58,"name":59,"color":97},{"assessments":120},{"slug":95},{"id":506,"slug":475,"title":476,"subtitle":97,"excerpt":507,"coverUrl":508,"locale":13,"readingMinutes":76,"publishedAt":477,"viewCount":509,"likeCount":19,"commentCount":19,"author":510,"vertical":511,"topic":512,"tags":513,"_count":518,"playground":519,"hasQuiz":17,"hasPlayground":17},"01a0ec73-0927-7298-a1ae-88b0d2458ab0","Next.js 15 made GET Route Handlers dynamic by default. What that changes for code written for 14, how to cache one on purpose in 16, plus a cheat sheet.","\u002Fmedia\u002Fcovers\u002Fnextjs-weekly-route-handlers-caching-streaming.png",292,{"id":104,"name":105,"username":106,"avatarUrl":97,"headline":107},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":110,"name":111},[514,515,516,517],{"slug":110,"name":111,"color":97},{"slug":66,"name":67,"color":97},{"slug":46,"name":47,"color":97},{"slug":58,"name":59,"color":97},{"assessments":120},{"slug":475},{"id":521,"slug":471,"title":472,"subtitle":97,"excerpt":522,"coverUrl":523,"locale":13,"readingMinutes":80,"publishedAt":473,"viewCount":524,"likeCount":19,"commentCount":19,"author":525,"vertical":526,"topic":527,"tags":528,"_count":533,"playground":534,"hasQuiz":17,"hasPlayground":17},"01a0b58a-7f56-736a-babf-1796c7b70c19","Next.js 16 renamed middleware.ts to proxy.ts and locked it to the Node.js runtime. Learn the network-boundary model, matcher config, and the migration path.","\u002Fmedia\u002Fcovers\u002Fnextjs-weekly-middleware-to-proxy-network-boundary.png",418,{"id":104,"name":105,"username":106,"avatarUrl":97,"headline":107},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":110,"name":111},[529,530,531,532],{"slug":110,"name":111,"color":97},{"slug":50,"name":51,"color":97},{"slug":58,"name":59,"color":97},{"slug":46,"name":47,"color":97},{"assessments":120},{"slug":471},{"id":536,"slug":467,"title":468,"subtitle":97,"excerpt":537,"coverUrl":538,"locale":13,"readingMinutes":100,"publishedAt":469,"viewCount":539,"likeCount":19,"commentCount":19,"author":540,"vertical":541,"topic":542,"tags":543,"_count":548,"playground":549,"hasQuiz":17,"hasPlayground":17},"01a09181-2f3b-7349-b651-fd9cdda0b931","How Next.js parallel routes (@slot) and intercepting routes ((.), (..), (...)) combine to build shareable, refreshable modals — verified against Next.js 16.3.","\u002Fmedia\u002Fcovers\u002Fnextjs-weekly-parallel-intercepting-routes-modals.png",411,{"id":104,"name":105,"username":106,"avatarUrl":97,"headline":107},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":110,"name":111},[544,545,546,547],{"slug":110,"name":111,"color":97},{"slug":74,"name":75,"color":97},{"slug":46,"name":47,"color":97},{"slug":58,"name":59,"color":97},{"assessments":120},{"slug":467},{"id":551,"slug":463,"title":464,"subtitle":97,"excerpt":552,"coverUrl":553,"locale":13,"readingMinutes":76,"publishedAt":465,"viewCount":554,"likeCount":19,"commentCount":19,"author":555,"vertical":556,"topic":557,"tags":558,"_count":563,"playground":564,"hasQuiz":17,"hasPlayground":17},"01a06d6c-41f6-77bd-95e3-cdd69a0f72eb","Next.js Server Actions look like plain functions but compile to public POST endpoints. Learn the mutation flow, built-in CSRF checks, and the auth you owe.","\u002Fmedia\u002Fcovers\u002Fnextjs-weekly-server-actions-mutations-security.png",421,{"id":104,"name":105,"username":106,"avatarUrl":97,"headline":107},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":110,"name":111},[559,560,561,562],{"slug":110,"name":111,"color":97},{"slug":74,"name":75,"color":97},{"slug":46,"name":47,"color":97},{"slug":58,"name":59,"color":97},{"assessments":120},{"slug":463},{"id":566,"slug":459,"title":460,"subtitle":97,"excerpt":567,"coverUrl":568,"locale":13,"readingMinutes":100,"publishedAt":461,"viewCount":569,"likeCount":19,"commentCount":19,"author":570,"vertical":571,"topic":572,"tags":573,"_count":578,"playground":579,"hasQuiz":17,"hasPlayground":17},"01a0499d-b520-778f-8286-8221ec03b8b5","How Next.js Cache Components decide what's static, what's cached, and what streams — the use cache directive, cacheLife, and Suspense explained.","\u002Fmedia\u002Fcovers\u002Fnextjs-weekly-cache-components-explained.png",601,{"id":104,"name":105,"username":106,"avatarUrl":97,"headline":107},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":110,"name":111},[574,575,576,577],{"slug":110,"name":111,"color":97},{"slug":74,"name":75,"color":97},{"slug":46,"name":47,"color":97},{"slug":58,"name":59,"color":97},{"assessments":120},{"slug":459},{"page":120,"perPage":486,"total":456,"totalPages":120},"\u003Cdiv class=\"shj shj-lang-tsx shj-multiline\" data-lang=\"tsx\">\u003Cdiv class=\"shj-scroll\">\u003Cdiv class=\"shj-numbers\">\u003Cdiv>1\u003C\u002Fdiv>\u003Cdiv>2\u003C\u002Fdiv>\u003Cdiv>3\u003C\u002Fdiv>\u003Cdiv>4\u003C\u002Fdiv>\u003Cdiv>5\u003C\u002Fdiv>\u003Cdiv>6\u003C\u002Fdiv>\u003Cdiv>7\u003C\u002Fdiv>\u003Cdiv>8\u003C\u002Fdiv>\u003Cdiv>9\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cdiv class=\"shj-code\">\u003Cspan class=\"shj-cmnt\">\u002F\u002F app\u002Fproducts\u002F[slug]\u002Fpage.tsx\n\u003C\u002Fspan>\u003Cspan class=\"shj-kwd\">export\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">async\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">function\u003C\u002Fspan> \u003Cspan class=\"shj-func\">generateMetadata\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> params \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> params\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-class\">Promise\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">&lt;\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> slug\u003Cspan class=\"shj-type\">: string\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">&gt;\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n  \u003Cspan class=\"shj-kwd\">const\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> slug \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan> \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">await\u003C\u002Fspan> params;\n  \u003Cspan class=\"shj-kwd\">const\u003C\u002Fspan> product \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">await\u003C\u002Fspan> \u003Cspan class=\"shj-func\">getProductFromCMS\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>slug\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>; \u003Cspan class=\"shj-cmnt\">\u002F\u002F ~2s on a slow day\n\u003C\u002Fspan>  \u003Cspan class=\"shj-kwd\">return\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n    title\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> product\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>name\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan>\n    openGraph\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> images\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">[\u003C\u002Fspan>product\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>heroImage\u003Cspan class=\"shj-bracket\">]\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan>\n  \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>;\n\u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003C\u002Fdiv>\u003C\u002Fdiv>\u003C\u002Fdiv>","\u003Cdiv class=\"shj shj-lang-html shj-multiline\" data-lang=\"html\">\u003Cdiv class=\"shj-scroll\">\u003Cdiv class=\"shj-numbers\">\u003Cdiv>1\u003C\u002Fdiv>\u003Cdiv>2\u003C\u002Fdiv>\u003Cdiv>3\u003C\u002Fdiv>\u003Cdiv>4\u003C\u002Fdiv>\u003Cdiv>5\u003C\u002Fdiv>\u003Cdiv>6\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cdiv class=\"shj-code\">\u003Cspan class=\"shj-var\">\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">&lt;\u003C\u002Fspan>\u003Cspan class=\"shj-var\">head\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">&gt;\u003C\u002Fspan>\n  \u003Cspan class=\"shj-cmnt\">&lt;!-- whatever resolved synchronously or came from a parent layout --&gt;\u003C\u002Fspan>\n  \u003Cspan class=\"shj-var\">\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">&lt;\u003C\u002Fspan>\u003Cspan class=\"shj-var\">meta\u003C\u002Fspan> \u003Cspan class=\"shj-class\">charset\u003C\u002Fspan>\u003Cspan class=\"shj-str\">\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">=\u003C\u002Fspan>\u003Cspan class=\"shj-str\">\"utf-8\"\u003C\u002Fspan> \u003Cspan class=\"shj-oper\">\u002F&gt;\u003C\u002Fspan>\n  \u003Cspan class=\"shj-cmnt\">&lt;!-- nothing reserved here for the pending tags — they arrive later, appended after &lt;body&gt; --&gt;\u003C\u002Fspan>\n\u003Cspan class=\"shj-var\">\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">&lt;\u002F\u003C\u002Fspan>\u003Cspan class=\"shj-var\">head\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">&gt;\u003C\u002Fspan>\n\u003Cspan class=\"shj-var\">\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">&lt;\u003C\u002Fspan>\u003Cspan class=\"shj-var\">body\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">&gt;\u003C\u002Fspan>\u003Cspan class=\"shj-cmnt\">&lt;!-- your page's visible content, already rendering --&gt;\u003C\u002Fspan>\u003Cspan class=\"shj-var\">\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">&lt;\u002F\u003C\u002Fspan>\u003Cspan class=\"shj-var\">body\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">&gt;\u003C\u002Fspan>\u003C\u002Fdiv>\u003C\u002Fdiv>\u003C\u002Fdiv>","\u003Cdiv class=\"shj shj-lang-bash shj-oneline\" data-lang=\"bash\">\u003Cspan class=\"shj-func\">curl\u003C\u002Fspan>\u003Cspan class=\"shj-kwd\"> -A\u003C\u002Fspan> \u003Cspan class=\"shj-str\">\"Slackbot\"\u003C\u002Fspan> https:\u002F\u002Fexample.com\u002Fproducts\u002Fwireless-mouse\u003C\u002Fdiv>","\u003Cdiv class=\"shj shj-lang-ts shj-multiline\" data-lang=\"ts\">\u003Cdiv class=\"shj-scroll\">\u003Cdiv class=\"shj-numbers\">\u003Cdiv>1\u003C\u002Fdiv>\u003Cdiv>2\u003C\u002Fdiv>\u003Cdiv>3\u003C\u002Fdiv>\u003Cdiv>4\u003C\u002Fdiv>\u003Cdiv>5\u003C\u002Fdiv>\u003Cdiv>6\u003C\u002Fdiv>\u003Cdiv>7\u003C\u002Fdiv>\u003Cdiv>8\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cdiv class=\"shj-code\">\u003Cspan class=\"shj-cmnt\">\u002F\u002F next.config.ts\n\u003C\u002Fspan>\u003Cspan class=\"shj-kwd\">import\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">type\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> \u003Cspan class=\"shj-class\">NextConfig\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">from\u003C\u002Fspan> \u003Cspan class=\"shj-str\">'next'\u003C\u002Fspan>;\n\n\u003Cspan class=\"shj-kwd\">const\u003C\u002Fspan> nextConfig\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-class\">NextConfig\u003C\u002Fspan> \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n  htmlLimitedBots\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-oper\">\u002FSlackbot\u003C\u002Fspan>\u003Cspan class=\"shj-kwd\">|\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">Twitterbot\u003C\u002Fspan>\u003Cspan class=\"shj-kwd\">|\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">facebookexternalhit\u003C\u002Fspan>\u003Cspan class=\"shj-kwd\">|\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">MyInternalLinkBot\u002F\u003C\u002Fspan>\u003Cspan class=\"shj-kwd\">i\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan>\n\u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>;\n\n\u003Cspan class=\"shj-kwd\">export\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">default\u003C\u002Fspan> nextConfig;\u003C\u002Fdiv>\u003C\u002Fdiv>\u003C\u002Fdiv>","\u003Cdiv class=\"shj shj-lang-tsx shj-multiline\" data-lang=\"tsx\">\u003Cdiv class=\"shj-scroll\">\u003Cdiv class=\"shj-numbers\">\u003Cdiv>1\u003C\u002Fdiv>\u003Cdiv>2\u003C\u002Fdiv>\u003Cdiv>3\u003C\u002Fdiv>\u003Cdiv>4\u003C\u002Fdiv>\u003Cdiv>5\u003C\u002Fdiv>\u003Cdiv>6\u003C\u002Fdiv>\u003Cdiv>7\u003C\u002Fdiv>\u003Cdiv>8\u003C\u002Fdiv>\u003Cdiv>9\u003C\u002Fdiv>\u003Cdiv>10\u003C\u002Fdiv>\u003Cdiv>11\u003C\u002Fdiv>\u003Cdiv>12\u003C\u002Fdiv>\u003Cdiv>13\u003C\u002Fdiv>\u003Cdiv>14\u003C\u002Fdiv>\u003Cdiv>15\u003C\u002Fdiv>\u003Cdiv>16\u003C\u002Fdiv>\u003Cdiv>17\u003C\u002Fdiv>\u003Cdiv>18\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cdiv class=\"shj-code\">\u003Cspan class=\"shj-cmnt\">\u002F\u002F app\u002Fproducts\u002F[slug]\u002Fpage.tsx\n\u003C\u002Fspan>\u003Cspan class=\"shj-kwd\">import\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">type\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> \u003Cspan class=\"shj-class\">ResolvingMetadata\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">from\u003C\u002Fspan> \u003Cspan class=\"shj-str\">'next'\u003C\u002Fspan>;\n\n\u003Cspan class=\"shj-kwd\">export\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">async\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">function\u003C\u002Fspan> \u003Cspan class=\"shj-func\">generateMetadata\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\n  \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> params \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> params\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-class\">Promise\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">&lt;\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> slug\u003Cspan class=\"shj-type\">: string\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">&gt;\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan>\n  parent\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-class\">ResolvingMetadata\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan>\n\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n  \u003Cspan class=\"shj-kwd\">const\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> slug \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan> \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">await\u003C\u002Fspan> params;\n  \u003Cspan class=\"shj-kwd\">const\u003C\u002Fspan> product \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">await\u003C\u002Fspan> \u003Cspan class=\"shj-func\">getProductFromCMS\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>slug\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\n  \u003Cspan class=\"shj-kwd\">const\u003C\u002Fspan> previousImages \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-kwd\">await\u003C\u002Fspan> parent\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>openGraph\u003Cspan class=\"shj-oper\">?.\u003C\u002Fspan>images \u003Cspan class=\"shj-oper\">??\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">[\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">]\u003C\u002Fspan>;\n\n  \u003Cspan class=\"shj-kwd\">return\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n    title\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> product\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>name\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan>\n    openGraph\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n      images\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">[\u003C\u002Fspan>product\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>heroImage\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> \u003Cspan class=\"shj-oper\">...\u003C\u002Fspan>previousImages\u003Cspan class=\"shj-bracket\">]\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan>\n    \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan>\n  \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>;\n\u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003C\u002Fdiv>\u003C\u002Fdiv>\u003C\u002Fdiv>",{"locked":18,"total":19,"comments":587},[]]