Skip to content

Async generateMetadata tags render into <div hidden> in <body> instead of <head> #2007

@jerome281

Description

@jerome281

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

  1. App Router project on vinext, Cloudflare Workers target.
  2. Root layout exports static metadata (works) — e.g. a description.
  3. 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 },
  };
}
  1. pnpm build, then serve the built Worker with npx wrangler dev --local.
  2. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions