Async generateMetadata tags render into a <div hidden> in <body> instead of <head>
Summary
On routes that resolve metadata via async generateMetadata, the produced tags — <title>, <meta name="description">, <link rel="canonical">, and all og:* / twitter:* tags — are emitted inside a <div hidden> in the <body>, not in <head>. Routes that use a static export const metadata render the same tags correctly into <head>.
This breaks rel=canonical (Google ignores canonical outside <head>) and Open Graph / Twitter Card previews (non-JS scrapers — Facebook, Slack, LinkedIn, WhatsApp, X — only parse <head>), and fails Lighthouse's meta-description SEO audit.
Expected behavior
Match Next.js: generateMetadata is awaited before the <head> shell is streamed, so the resolved tags land in <head> — identical to static metadata.
Actual behavior
The <head> shell is flushed before the async page metadata resolves. The late-resolved tags are then rendered inline in the document body inside a <div hidden> and never hoisted into <head>.
Reproduction
- App Router project on vinext, Cloudflare Workers target.
- Root layout exports static
metadata (works) — e.g. a description.
- A nested dynamic route exports async
generateMetadata:
// app/[locale]/deals/page.tsx
export async function generateMetadata({ searchParams }): Promise<Metadata> {
const description = 'Browse the latest UK running shoe deals…';
const canonical = canonicalFor('/uk/deals', page);
return {
title: 'Running Shoe Deals',
description,
alternates: { canonical },
openGraph: { title: 'Running Shoe Deals', description, url: canonical },
twitter: { card: 'summary_large_image', title: 'Running Shoe Deals', description },
};
}
pnpm build, then serve the built Worker with npx wrangler dev --local.
curl http://localhost:8787/uk/deals and inspect where the tags land.
Observed output (abridged)
<head> contains only the synchronously-available content (charset, viewport, stylesheet, font preloads, JSON-LD from the explicit <head> JSX in the root layout). The page metadata is in the body:
<body>
…
<div hidden>
<title>Running Shoe Deals | …</title>
<meta name="description" content="Browse the latest UK running shoe deals…">
<meta property="og:title" content="…">
<meta property="og:url" content="…">
<link rel="canonical" href="…">
<meta name="twitter:card" content="summary_large_image">
</div>
…
</body>
The home route (static export const metadata) renders the exact same fields correctly inside <head> in the same build — confirming the split is specifically static-vs-async metadata, not a config or markup problem.
What I ruled out
- Not a version regression. Reproduces on both 0.0.53 and 0.1.2 (latest at time of writing).
- Not fixable at app level. Rendering
<link rel="canonical"> / <meta> as JSX directly in the page component body also lands in <body> — React 19's native document-metadata hoisting does not run in the vinext SSR build for body-rendered tags.
- Not a dev-only artifact. Confirmed on a production
vinext build served via wrangler dev --local (real Workers runtime). Note: vinext dev is not a valid repro surface — it parks all metadata (even static) in the body; only the production build hoists static metadata. Also vinext start (Node) is unusable for Workers apps that read CF env in proxy.ts (Cannot read properties of undefined (reading '<ENV_VAR>')).
Suspected cause
vinext/dist/shims/metadata.js MetadataHead returns a Fragment of <title>/<meta>/<link> elements and relies on hoisting into <head>. For static metadata, vinext resolves it synchronously and places MetadataHead in the head region before the shell flushes. For async generateMetadata, resolution happens after the head has been flushed, so React renders the tags inline in the body. The fix is to await page-level generateMetadata (and generateViewport) before streaming the <head> shell, as Next.js does.
Environment
- vinext: 0.0.53 and 0.1.2 (both reproduce)
- next: 16.2.6
- react / react-dom: 19.2.6
- vite: 8.0.14
- @vitejs/plugin-rsc: 0.5.26
- @vitejs/plugin-react: 6.0.2
- Node: 22.14.0
- Target: Cloudflare Workers (
@cloudflare/vite-plugin, served via wrangler dev --local)
Related
Impact
rel=canonical ignored by Google (duplicate-content / consolidation risk).
- No Open Graph / Twitter previews from non-JS scrapers — significant for share-driven sites.
- Lighthouse SEO capped (fails
meta-description).
Async
generateMetadatatags render into a<div hidden>in<body>instead of<head>Summary
On routes that resolve metadata via async
generateMetadata, the produced tags —<title>,<meta name="description">,<link rel="canonical">, and allog:*/twitter:*tags — are emitted inside a<div hidden>in the<body>, not in<head>. Routes that use a staticexport const metadatarender the same tags correctly into<head>.This breaks
rel=canonical(Google ignores canonical outside<head>) and Open Graph / Twitter Card previews (non-JS scrapers — Facebook, Slack, LinkedIn, WhatsApp, X — only parse<head>), and fails Lighthouse'smeta-descriptionSEO audit.Expected behavior
Match Next.js:
generateMetadatais awaited before the<head>shell is streamed, so the resolved tags land in<head>— identical to staticmetadata.Actual behavior
The
<head>shell is flushed before the async page metadata resolves. The late-resolved tags are then rendered inline in the document body inside a<div hidden>and never hoisted into<head>.Reproduction
metadata(works) — e.g. adescription.generateMetadata:pnpm build, then serve the built Worker withnpx wrangler dev --local.curl http://localhost:8787/uk/dealsand inspect where the tags land.Observed output (abridged)
<head>contains only the synchronously-available content (charset, viewport, stylesheet, font preloads, JSON-LD from the explicit<head>JSX in the root layout). The page metadata is in the body:The home route (static
export const metadata) renders the exact same fields correctly inside<head>in the same build — confirming the split is specifically static-vs-async metadata, not a config or markup problem.What I ruled out
<link rel="canonical">/<meta>as JSX directly in the page component body also lands in<body>— React 19's native document-metadata hoisting does not run in the vinext SSR build for body-rendered tags.vinext buildserved viawrangler dev --local(real Workers runtime). Note:vinext devis not a valid repro surface — it parks all metadata (even static) in the body; only the production build hoists static metadata. Alsovinext start(Node) is unusable for Workers apps that read CF env inproxy.ts(Cannot read properties of undefined (reading '<ENV_VAR>')).Suspected cause
vinext/dist/shims/metadata.jsMetadataHeadreturns aFragmentof<title>/<meta>/<link>elements and relies on hoisting into<head>. For static metadata, vinext resolves it synchronously and placesMetadataHeadin the head region before the shell flushes. For asyncgenerateMetadata, resolution happens after the head has been flushed, so React renders the tags inline in the body. The fix is to await page-levelgenerateMetadata(andgenerateViewport) before streaming the<head>shell, as Next.js does.Environment
@cloudflare/vite-plugin, served viawrangler dev --local)Related
<body>rather than<head>.Impact
rel=canonicalignored by Google (duplicate-content / consolidation risk).meta-description).