Streaming Metadata in Next.js: Why Our Twitter Cards Showed Yesterday's Title
Next.js 15 streams metadata by default so pages render faster, but social scrapers don't wait around. Here's how we tracked down stale Twitter cards and the pattern we settled on in production.
A client pinged us on a Monday: their new product launch page was live, cache was warm, but every share on X showed the previous week's title and a broken OG image. Browser view-source looked fine. The culprit wasn't our code — it was how Next.js 15 streams <head> by default, and how social scrapers interpret a half-rendered document.
This is the breakdown of what we found, why it happens, and the pattern we now use for any page that needs reliable social previews.
The symptom: fresh HTML, stale cards
The page in question used generateMetadata to fetch the latest campaign copy from a CMS. In the browser, everything rendered correctly — the title tag updated, the og:image resolved, Core Web Vitals were green. But:
- X's card validator showed the old title.
- LinkedIn's Post Inspector returned a 200 with no OG tags at all.
- Slack unfurled a generic fallback.
- Facebook's sharing debugger showed partial metadata, missing
og:description.
What they all had in common: each scraper reads the first chunk of HTML it receives, applies a short timeout, and bails. If your <meta> tags aren't in that first flush, they're invisible to the crawler, even though a real browser would eventually see them.
Why the browser worked and the scraper didn't
Since Next.js 15.2, metadata can be streamed. Instead of blocking the response until generateMetadata resolves, Next.js sends the shell, then injects resolved metadata into <head> later in the stream via a small inline script. Chrome, Safari, and Firefox handle this fine — they're parsing continuously and the title updates mid-stream.
Scrapers don't. Most of them use lightweight HTTP clients that:
- Request the URL.
- Read until they see
</head>or hit a byte/time limit. - Parse whatever they got.
If your generateMetadata takes 400ms because it hits a CMS, the first chunk goes out at ~50ms with no <meta property="og:*"> tags in it. The scraper sees an empty head, gives up, and caches that result for hours or days.
Reproducing it locally
You can see this with curl and a slow data fetch. Here's a trimmed version of the route we had:
// app/campaigns/[slug]/page.tsx
import type { Metadata } from 'next';
import { getCampaign } from '@/lib/cms';
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata(
{ params }: Props,
): Promise<Metadata> {
const { slug } = await params;
const campaign = await getCampaign(slug); // ~400ms from CMS
return {
title: campaign.title,
description: campaign.summary,
openGraph: {
title: campaign.title,
description: campaign.summary,
images: [campaign.ogImage],
},
twitter: {
card: 'summary_large_image',
title: campaign.title,
images: [campaign.ogImage],
},
};
}
export default async function Page({ params }: Props) {
const { slug } = await params;
const campaign = await getCampaign(slug);
return <CampaignView data={campaign} />;
}
Hit it with a scraper-like client that only reads the first 8KB:
curl -s -N https://example.com/campaigns/launch \
--max-time 1 | head -c 8192 | grep -E 'og:|twitter:'
Nothing. The <head> we got back only contains the viewport meta and a stylesheet link. The OG tags arrive seconds later, inlined via a <script> that updates the DOM — which no crawler will execute.
The fix: opt out of streaming metadata for pages that need it
Next.js gives you a config flag for exactly this. In next.config.ts:
import type { NextConfig } from 'next';
const config: NextConfig = {
experimental: {
streamingMetadata: false,
},
};
export default config;
That flips the behaviour globally: Next.js will wait for generateMetadata to resolve before flushing any HTML. Your TTFB gets worse by whatever your metadata fetch costs, but your social cards work.
That's a blunt trade-off, and we didn't love it. Most pages in the app didn't need it — only the landing, blog, and campaign routes did.
A more surgical approach
What we actually shipped: keep streaming metadata on, but make sure the metadata fetch is fast enough that it beats the first flush on routes that matter.
Two levers:
- Cache the metadata fetch separately from the page fetch. The data
generateMetadataneeds is usually a tiny subset — title, description, image URL. Store it in a lean cache tier (Redis, KV, or Next's data cache with a tight tag) so it resolves in under 20ms. - Pre-generate at build or on-demand. For routes with a known set of slugs, use
generateStaticParams. Static routes bypass the streaming question entirely because metadata is baked in.
Here's the refactor:
// lib/cms.ts
import { unstable_cache } from 'next/cache';
export const getCampaignMeta = unstable_cache(
async (slug: string) => {
const res = await fetch(`${process.env.CMS}/meta/${slug}`, {
next: { tags: [`campaign-meta:${slug}`] },
});
return res.json() as Promise<{
title: string;
summary: string;
ogImage: string;
}>;
},
['campaign-meta'],
{ revalidate: 300 },
);
The /meta/:slug endpoint returns ~400 bytes. It's served from the CMS's edge cache. In our setup it resolves in ~15ms p95. That's fast enough that even with streaming metadata on, the OG tags make it into the first flush.
We then invalidate explicitly on publish:
// app/api/webhooks/cms/route.ts
import { revalidateTag } from 'next/cache';
export async function POST(req: Request) {
const { slug, secret } = await req.json();
if (secret !== process.env.CMS_WEBHOOK_SECRET) {
return new Response('nope', { status: 401 });
}
revalidateTag(`campaign-meta:${slug}`);
return Response.json({ ok: true });
}
Verifying it actually works
Don't trust the browser. Use the tools scrapers use:
- X Card Validator — shows exactly what the crawler parsed.
- Facebook Sharing Debugger — the "Scraped URL" view is honest about what was received.
- LinkedIn Post Inspector — the strictest of the three; if LinkedIn is happy, usually everyone is.
curlwith--max-time 1 --range 0-8191to simulate an impatient crawler.
We added a tiny CI check that fetches each public route with a 500ms timeout and asserts that og:title, og:image, and twitter:card are present in the first 16KB. It caught two regressions in the first month after we added it — both times someone had moved a fetch out of the cached helper and back into the page component.
A note on og:image routes
If your OG image is itself a dynamic route (/opengraph-image.tsx), the scraper has to make a second request for it. That one can be slow — most crawlers will wait longer for an image than for HTML — but if the image route depends on the same slow CMS call, you're compounding the problem. Cache the image or precompute it. Vercel's edge functions handle this well; on other runtimes you'll want a CDN rule that caches /opengraph-image responses aggressively with a stale-while-revalidate policy.
When streaming metadata is still the right call
We left streaming on for the authenticated app shell: dashboards, settings, anything behind login. Those pages are never scraped — robots can't reach them — and the TTFB win is real. The rule we use now:
- Public, shareable, SEO-sensitive → metadata must be in the first flush. Cache it hard.
- Authenticated or non-indexable → stream it, don't block the shell.
You can enforce this at code review by greping for generateMetadata and checking whether the data it depends on is cached. It's not a lint rule yet, but it could be.
Where we'd start
If you're seeing stale or missing social cards on a Next.js App Router app in 2026, don't start with your meta tags. Start with curl --max-time 1 against the route and look at the first 8KB. If the OG tags aren't there, your generateMetadata is losing a race against the first flush. Pull the metadata fetch into its own cached helper, trim the payload to the bare minimum, and verify with the three scraper tools before you ship. If the fetch can't be made fast — a legacy API, a strict rate limit — turn streamingMetadata off for that segment and eat the TTFB cost. A 200ms slower response is worth more than a week of broken shares.
If you want a second pair of eyes on your App Router performance work, our team does this kind of audit as part of our web development engagements.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Why Your Next.js Middleware Is Secretly Running Twice (And How to Stop It)
A war story about a Next.js app where middleware ran twice per request, doubled our auth bill, and broke rate limiting. Here's what caused it and how we found it.
React 19's `useFormStatus` Is a Trap If You Use It Wrong
`useFormStatus` looks like the obvious way to wire pending states into forms with Server Actions. It is — until you nest it, memoize it, or try to read it from the component that owns the form.
The `use` Hook Meets Suspense: What Actually Happens When You Pass a Promise to a Client Component
React 19's `use` hook lets you unwrap promises inside client components. It's elegant until a parent re-renders and your tab goes blank. Here's what's actually happening and how to not get burned.
