cloudflare / cloudflare/vinext
Async generateMetadata tags render into <div hidden> in <body> instead of <head>
- Dominant language
- TypeScript
- Stars
- 8.8k
- Forks
- 406
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 120
Description
# Async `generateMetadata` tags render into a `
## Summary
On routes that resolve metadata via **async `generateMetadata`**, the produced tags — ``, `<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`:
```ts
// 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 },
};
}
```
4. `pnpm build`, then serve the built Worker with `npx wrangler dev --local`.
5. `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:
```html
<body>
…
<div hidden>
<title>Running Shoe Deals | …
…
```
The **home** route (static `export const metadata`) renders the exact same fields correctly inside `` 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 `` / `` as JSX directly in the page component body also lands in `` — 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 '')`).
## Suspected cause
`vinext/dist/shims/metadata.js` `MetadataHead` returns a `Fragment` of ``/`<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
- #1795 (closed) reworked prerender *cache-metadata readiness* (awaiting render-side metadata effects before applying cache/header decisions). Adjacent sequencing area, but it does not address the **physical placement** of async-resolved metadata tags in the document — they still render in `<body>` rather than `<head>`.
## 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`).
Contributor guide
Assessment
This issue has not been assessed yet.