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.

How metadata flushes ahead of streamed body content generateMetadata resolves fast and the head plus shell flush first; a Suspense boundary streams slow content afterward, so the crawler receives the head early even though the body arrives progressively. Response timeline — head first, slow body streams after time → generateMetadata() fast, cached fetch flush 1: head + shell title, meta, canonical Suspense fallback placeholder in shell flush 2: slow content streamed when ready
Metadata resolves before the first flush, so the head and shell ship early while Suspense streams the slow body afterward.

Step-by-step fix

  1. Keep generateMetadata fast 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}` },
      };
    }
  2. 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>
      );
    }
  3. 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.

  4. Reuse the cached fetch across metadata and body. Because Next dedupes identical fetch calls in a request, calling getProduct in both generateMetadata and 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();
    }
  5. 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) }} />;
Sorting page data between generateMetadata and a Suspense boundary Only head data — title, description, canonical — belongs in generateMetadata, which blocks the first flush, while slow body content like reviews and recommendations belongs inside Suspense and streams afterward. Put each piece of data where it will not stall the head route data generateMetadata — blocks first flush title description canonical — keep fast and cached Suspense — streams after the shell reviews recommendations heavy widgets — never gate the head
Sort by what the head needs: fast head data stays in generateMetadata, and every slow body section goes behind a Suspense boundary to stream later.

Validation

Confirm the head is complete in the earliest bytes of the response and that streaming only defers body content, never metadata.

Four checks that streaming defers only body content, never metadata Confirm the head arrives in the first chunk, TTFB stays low, Search Console rendered HTML matches the route, and the resolved content rather than the skeleton is indexed. Verify the head ships first and only the body streams 1 · Head in the first chunk curl -sN shows title + canonical first, before any streamed section 2 · TTFB not stalled a slow generateMetadata delays the first flush — cache until it is fast 3 · GSC rendered HTML matches URL Inspection shows the route title and canonical, not the shell 4 · Real content is indexed the completed response holds content, not the Suspense skeleton
Four checks confirm the metadata ships in the first bytes and only the body streams — the head is never deferred and the skeleton is never what gets indexed.
  • 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 generateMetadata shows 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.
A slow metadata fetch inflates TTFB compared with a cached one Two response timelines: a slow uncached fetch inside generateMetadata pushes the first flush far to the right, while a fast cached fetch flushes the head and shell early and streams the slow body afterward. Where the head data lives decides when the first byte ships request start slow fetch in generateMetadata blocking metadata fetch flush: head + shell high TTFB fast cached metadata cached flush: head + shell slow body streams after low TTFB
Moving the slow work out of generateMetadata flushes the head and shell early; only the body waits, so TTFB stays low while the crawler still gets the tags first.

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.

← Back to Next.js App Router SEO