Streaming Metadata with Suspense in Next.js
The Next.js App Router streams: it can flush an initial shell to the browser and send slower parts of the page as they finish rendering, using Suspense boundaries as the seams. That is excellent for perceived performance, but it raises a fair question for SEO — if the body arrives in pieces, when does the metadata get resolved and written to the head? This article extends Next.js App Router SEO into the streaming case, building on the dynamic Metadata API patterns.
Problem Statement
Metadata in the App Router comes from a static metadata export or an async generateMetadata function, both resolved during the server render. When a route streams, Next still guarantees that resolved metadata is emitted into the document head rather than sprinkled into the body — the framework buffers the head so the tags land where they belong. The trap is not that streaming misplaces the head; it is that generateMetadata sits on the critical path to the first flush. If you put a slow, uncached fetch inside it, you delay the entire response, including the shell, because nothing can be sent until the head is known.
The pattern that keeps both fast and correct is a division of labor: generateMetadata fetches only what the head needs, quickly and from cache, so the shell and head flush early; genuinely slow content lives in components wrapped in Suspense, streaming in afterward without holding up the metadata.
Step-by-step fix
-
Keep
generateMetadatafast and scoped to the head. Fetch only what the title, description, and canonical require, and let the fetch cache dedupe it against the page’s own data fetch.// app/products/[id]/page.tsx import type { Metadata } from 'next'; export async function generateMetadata( { params }: { params: { id: string } } ): Promise<Metadata> { const product = await getProduct(params.id); // cached; deduped with the page return { title: product.name, description: product.summary, alternates: { canonical: `https://example.com/products/${product.id}` }, }; } -
Wrap slow, non-metadata content in Suspense. The shell and head flush immediately; the expensive section streams in behind a fallback.
import { Suspense } from 'react'; export default function Page({ params }: { params: { id: string } }) { return ( <main> <ProductHeader id={params.id} /> <Suspense fallback={<ReviewsSkeleton />}> <Reviews id={params.id} /> {/* slow, streamed */} </Suspense> </main> ); } -
Do not gate the head on the slow data. Keep the reviews fetch out of
generateMetadata; it is body content, not head content, so it must not block the first flush. -
Reuse the cached fetch across metadata and body. Because Next dedupes identical
fetchcalls in a request, callinggetProductin bothgenerateMetadataand the component costs one request.async function getProduct(id: string) { const res = await fetch(`https://api.example.com/products/${id}`, { next: { revalidate: 3600 }, }); return res.json(); } -
Render JSON-LD in the server component body. It ships in the shell flush alongside the head, so structured data is present early.
const ld = { '@context': 'https://schema.org', '@type': 'Product', name: product.name }; return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />;
Validation
Confirm the head is complete in the earliest bytes of the response and that streaming only defers body content, never metadata.
- Head appears before streamed content.
curl -sN https://example.com/products/42/and watch the stream: the<title>and canonical should arrive in the first chunk, before the streamed section. - Metadata does not stall the first byte. Measure TTFB; a slow
generateMetadatashows up as a delayed first flush. Cache the fetch until it is fast. - Rendered HTML matches in GSC. URL Inspection’s rendered HTML should show the route-specific title and canonical, confirming the crawler resolved the streamed response.
- Suspense fallback is not the indexed content. Verify the streamed section’s real content — not the skeleton — is present in the completed response.
// Assert the head is in the first streamed chunk, ahead of the slow section
const res = await fetch('https://example.com/products/42/');
const reader = res.body.getReader();
const { value } = await reader.read();
const firstChunk = new TextDecoder().decode(value);
console.assert(/<title>[^<]+<\/title>/.test(firstChunk), 'Title not in first flush');
Common Pitfalls
- Slow fetch inside
generateMetadata. It blocks the first flush and inflates TTFB; cache it or move the work into a streamed component. - Putting body data in the metadata function. Fetching reviews or recommendations there delays the head for content the head does not need.
- Setting metadata from a client component. Client-side head edits arrive after the server response the crawler reads first; keep metadata server-rendered.
- Un-deduped duplicate fetches. Different fetch options or URLs between metadata and the component defeat Next’s dedupe, doubling the request and the latency.
- Treating the Suspense fallback as content. If the streamed section never resolves, the skeleton is what remains; ensure the real content completes within the response.
Frequently Asked Questions
Does streaming with Suspense delay when Next.js metadata is sent?
It can. Next resolves generateMetadata as part of rendering, and when a route streams, the framework holds the head until metadata is ready so the tags still land in the document head rather than mid-body. The risk is putting slow data behind generateMetadata itself: that delays the first flush of the whole response. Keep metadata fetches fast and cached, and let slow content stream inside Suspense boundaries instead.
Should slow data live in generateMetadata or in a streamed component?
Put only the data the head needs in generateMetadata, and keep it fast and cached because it blocks the initial response. Move slow, non-metadata content into components wrapped in Suspense so they stream in after the shell and head are sent. This keeps the title, description, and canonical early in the response while the expensive body arrives progressively.
Can a crawler miss metadata that streams in late?
In the App Router, Next ensures resolved metadata is emitted into the document head, not appended after body content, so a correctly structured route does not scatter tags into the body. The real failure is a metadata fetch that is so slow the response times out, or metadata set from a client component after load. Keeping generateMetadata server-side and fast ensures the head is present when the crawler parses the response.
Related
- Next.js App Router SEO — the App Router’s overall SEO surface.
- Dynamic Metadata with the Next.js Metadata API — generateMetadata for data-driven routes.
- Incremental & Streaming SSR for SEO — how streaming responses reach crawlers.
← Back to Next.js App Router SEO