Metadata and dynamic OG cards
How Next.js builds a page's title, search description, and social share card from the Metadata API, dynamic generateMetadata, and generated Open Graph images.
A teammate adds an invoice detail page at app/(app)/invoices/[id]/page.tsx, and the routing all works. But three things the route tree never gave you are now missing. The browser tab reads a bare URL instead of the invoice number. A search engine that crawls the page has no description to show. And when someone pastes the link into Slack, the unfurl is blank: no logo, no invoice number, no customer name, so it looks like a dead link nobody clicks.
Those three outputs all come from one surface, a page’s metadata. This lesson teaches the three mechanisms that produce it: the static metadata export, the dynamic generateMetadata function, and the colocated opengraph-image file. One idea ties them together: metadata is data about the page, authored where the page lives. You use static metadata when the values are known up front, generateMetadata when they depend on the resource, and an image file when the preview is a picture. By the end you’ll have an invoice page whose title, description, and social card all track the invoice it renders, that reads the database exactly once per request, and that stays correct when the invoice is archived. This work will feel familiar, because these exports live in the same page.tsx and layout.tsx files you already write. Metadata is just more of the route.
metadata: page data authored where the page lives
Section titled “metadata: page data authored where the page lives”The default mechanism is a plain exported constant. You add it to any layout.tsx or page.tsx, and Next renders the corresponding <head> tags for you. You write no <head> element and no hand-written <meta> tags.
import type { Metadata } from 'next';
export const metadata: Metadata = { title: 'Invoices', description: 'Create, send, and track invoices for your organization.',};That import type matters. Metadata is a type, never a runtime value, so it imports on its own type-only line. The project’s verbatimModuleSyntax setting enforces this, and a plain import here would be a lint error. The object’s keys map one-to-one onto the tags Next emits: title becomes <title>, and description becomes <meta name="description">.
Metadata merges down the tree
Section titled “Metadata merges down the tree”The merging is what lets this model scale across a large app. Next evaluates metadata from the root layout down to the leaf page and merges the objects together. The root layout sets brand-wide defaults that every page inherits, and each page below it overrides only the keys that are page-specific. You set description once at the root as a sane fallback, and an individual page replaces it with something sharper, so you never repeat the shared values.
The merge is shallow, and this is the one rule that catches people out. A key set lower in the tree replaces the same key higher up; it does not deep-merge. That’s fine for flat values like title and description. But openGraph is an object, so if a page sets any field inside openGraph, it replaces the entire openGraph object from the parent. Every field the root set is gone, not just the one the page changed. The fix is a one-liner: pull the shared OG fields into a constant and spread them into each page’s openGraph, so the page-level fields layer on top instead of wiping the parent’s.
The ladder below makes the top-down flow concrete.
app/layout.tsx
title.template: '%s — Acme'
metadataBase
openGraph: { siteName }
app/(app)/layout.tsx
inherits — sets nothing
app/(app)/invoices/[id]/page.tsx
title: 'Invoice INV-1042'
description
The ladder shows the title doing something special. The page set title: 'Invoice INV-1042', yet the tab reads Invoice INV-1042 — Acme. That extra suffix comes from the title template, which every project uses and which is worth understanding in detail.
title: the template and the default
Section titled “title: the template and the default”You almost never want a page to spell out the full tab title. You want every page suffixed with the product name, like Invoices — Acme and Settings — Acme, without each page restating the — Acme suffix. The root layout sets a template once, and each page contributes only its own segment.
export const metadata: Metadata = { title: { template: '%s — Acme', default: 'Acme', },};
// app/(app)/invoices/page.tsxexport const metadata: Metadata = { title: 'Invoices',};// resolves to: Invoices — AcmeThe root sets title as an object, not a string. template is the pattern, and %s is the slot each child page’s title drops into. default is the title used when a page supplies none of its own. It becomes required the moment you set a template, because a route with no child title, such as the root itself or a bare segment, still needs something to render. The template applies only to the page’s descendants, never to the segment that defines it, so the root layout itself renders default rather than the template.
export const metadata: Metadata = { title: { template: '%s — Acme', default: 'Acme', },};
// app/(app)/invoices/page.tsxexport const metadata: Metadata = { title: 'Invoices',};// resolves to: Invoices — AcmeA child page sets title as a plain string. Next drops it into %s, so 'Invoices' resolves to Invoices — Acme (the last line), and the page never types the suffix. Sometimes a page needs to escape the template entirely, such as a bare marketing landing page that should read just Acme with no suffix. For that, it sets title: { absolute: 'Acme' } instead of a string, and the template is ignored for that page.
That covers the part of the static surface you reach for most. The full surface is larger: metadata also accepts keywords, authors, creator, robots, icons, alternates, verification, openGraph, and twitter, among others. You don’t need to tour all of them. The six fields a SaaS reaches for are title, description, openGraph, twitter, alternates.canonical, and metadataBase. You add the rest the day a specific need shows up. In particular, robots and icons get their own treatment in the next lesson alongside the rest of the root SEO bundle.
metadataBase and the canonical URL
Section titled “metadataBase and the canonical URL”Two of those six fields are about getting absolute URLs right. They belong together because they’re the same discipline: telling crawlers and link scrapers the real, canonical address of your content.
metadataBase sets the origin once
Section titled “metadataBase sets the origin once”Several metadata fields hold URLs: the canonical tag, the Open Graph image, and alternate-language links. Scrapers and search engines require those to be absolute (https://app.acme.com/invoices), not relative (/invoices), because a relative URL is meaningless to a bot fetching your tags from the outside. Writing the full origin into every field is repetitive and easy to get wrong. So you set the origin once, in the root layout, and write relative paths everywhere below. This is where you finally fill in that export const metadata = { /* ... */ } the fonts lesson left as a placeholder in app/layout.tsx, since the root layout is where brand-wide metadata belongs.
export const metadata: Metadata = { metadataBase: new URL('https://app.acme.com'), // ...title template, etc.};With metadataBase set, any URL-valued field below it can be a relative path, and Next composes it into an absolute URL against that base. The precise behavior is worth spelling out, because it’s a common source of confusion. A relative URL in a metadata field without a metadataBase is a build error: Next refuses to emit a meaningless relative tag. And when you don’t set metadataBase at all, Next infers a default origin, which is your Vercel deployment URL in production (VERCEL_URL), or localhost:3000 in development.
That inference is exactly why you set metadataBase explicitly anyway. The inferred VERCEL_URL is a per-deployment hostname, a different random subdomain for every preview build, so your canonical tags and OG image URLs would point at a throwaway origin instead of your real one. Pinning metadataBase to the production origin keeps the canonical address correct. Set it once and never think about it again.
alternates.canonical consolidates duplicate URLs
Section titled “alternates.canonical consolidates duplicate URLs”The same page content is often reachable from several URLs. /invoices, /invoices?ref=email, /invoices?sort=date, and the trailing-slash variant all render the same list, but a search engine sees them as distinct pages and splits your ranking signal across the duplicates instead of pooling it on one. The canonical URL is the fix: a tag that names the one URL you want indexed, so the link equity that would scatter across duplicates consolidates onto it.
export const metadata: Metadata = { alternates: { canonical: '/invoices', },};It’s a relative path because metadataBase composes it into the absolute canonical URL. The habit to build is to set alternates.canonical on any page where searchParams can spin up duplicate-content variants: list views with sorting and filtering, and anything reachable with tracking parameters appended.
generateMetadata: when the title depends on the resource
Section titled “generateMetadata: when the title depends on the resource”Static metadata has a hard limit: it’s a constant, evaluated without knowing which invoice. But the tab title you actually want is Invoice INV-1042 — Acme, and INV-1042 lives in the database, keyed by the [id] in the URL. A constant can’t reach the database. When the title, description, or card depends on the resource the page renders, you reach for the dynamic tool: generateMetadata.
It’s an async function you export instead of the constant. The return shape is the same, a Metadata object, but now you can await a database read first and build the metadata from the result.
import type { Metadata } from 'next';import { notFound } from 'next/navigation';
import { getInvoice } from '@/db/queries/invoices';
export async function generateMetadata( { params }: PageProps<'/invoices/[id]'>,): Promise<Metadata> { const { id } = await params; const invoice = await getInvoice(id); if (!invoice) notFound();
return { title: `Invoice ${invoice.number}`, description: `Invoice ${invoice.number} for ${invoice.customerName}.`, openGraph: { title: `Invoice ${invoice.number}`, type: 'website' }, };}The function is async and returns Promise<Metadata>. It receives the same params the page receives, and in Next 16 params is a Promise, so you await it. PageProps<'/invoices/[id]'> is the typed helper Next generates from your route (typed routes are on for this project). It types params as { id: string } wrapped in a Promise, with no hand-written prop type. The Promise here is the async request model from earlier: await it and move on.
import type { Metadata } from 'next';import { notFound } from 'next/navigation';
import { getInvoice } from '@/db/queries/invoices';
export async function generateMetadata( { params }: PageProps<'/invoices/[id]'>,): Promise<Metadata> { const { id } = await params; const invoice = await getInvoice(id); if (!invoice) notFound();
return { title: `Invoice ${invoice.number}`, description: `Invoice ${invoice.number} for ${invoice.customerName}.`, openGraph: { title: `Invoice ${invoice.number}`, type: 'website' }, };}Read the resource. getInvoice(id) is your single-record read: it returns the invoice row, or null when no invoice matches that id.
import type { Metadata } from 'next';import { notFound } from 'next/navigation';
import { getInvoice } from '@/db/queries/invoices';
export async function generateMetadata( { params }: PageProps<'/invoices/[id]'>,): Promise<Metadata> { const { id } = await params; const invoice = await getInvoice(id); if (!invoice) notFound();
return { title: `Invoice ${invoice.number}`, description: `Invoice ${invoice.number} for ${invoice.customerName}.`, openGraph: { title: `Invoice ${invoice.number}`, type: 'website' }, };}Handle the missing invoice here. notFound() throws to the route’s not-found.tsx boundary, the same boundary you met earlier, and rendering stops at that point. Putting the existence check in metadata, against the same read the page uses, guarantees the page and its <head> agree on whether the invoice exists. redirect() works here too, by the same mechanism.
import type { Metadata } from 'next';import { notFound } from 'next/navigation';
import { getInvoice } from '@/db/queries/invoices';
export async function generateMetadata( { params }: PageProps<'/invoices/[id]'>,): Promise<Metadata> { const { id } = await params; const invoice = await getInvoice(id); if (!invoice) notFound();
return { title: `Invoice ${invoice.number}`, description: `Invoice ${invoice.number} for ${invoice.customerName}.`, openGraph: { title: `Invoice ${invoice.number}`, type: 'website' }, };}Build the metadata from the resource. The title interpolates the invoice number, the description names the customer, and openGraph carries a card-specific title. Because the page sets openGraph, the shallow-merge rule applies: this object replaces the root’s openGraph entirely, so any shared OG field the root defined has to be spread back in here.
One rule trips people into a build error, so it’s worth stating plainly: you cannot export both metadata and generateMetadata from the same file. A file declares its metadata one way or the other, the constant when it’s static and the function when it depends on the resource. Each file picks one.
Reading the resource once: the cache() dedup
Section titled “Reading the resource once: the cache() dedup”Look closely at what now happens on a single request to the invoice page. generateMetadata runs to build the <head>, and it reads the invoice. Then the page component runs to build the body, and it reads the same invoice. Two functions read the same data on one page, and done naively that’s two database round-trips for one page load: double the latency and double the load on Postgres for a single render. On a list of links each prefetching their metadata, that doubling adds up fast.
The fix is a tool you already have: React’s cache(). You wrap the read in it once, and every call to that read within the same request shares one result. The first call hits the database, and the second gets the stored result for free. Both generateMetadata and the page call the same wrapped function, so the page reads the invoice once and reuses it.
export const getInvoice = async (id: string) => { const invoice = await db.query.invoices.findFirst({ where: eq(invoices.id, id), }); return invoice ?? null;};One DB hit per call, so two per page. A plain async function runs its body every time it’s invoked. generateMetadata calls it and Postgres is queried, then the page calls it and Postgres is queried again. The same row is fetched twice.
export const getInvoice = cache(async (id: string) => { const invoice = await db.query.invoices.findFirst({ where: eq(invoices.id, id), }); return invoice ?? null;});One DB hit per request, shared. Wrapping the same function in cache() memoizes it for the duration of the request. generateMetadata calls it and Postgres is queried once, then the page calls it and gets the stored result. One round-trip serves two consumers.
It’s worth knowing why you reach for cache() here rather than assuming the framework handles it. When you read data with fetch, Next deduplicates identical fetch calls automatically within a render, so you’d get this for free. But this read isn’t a fetch; it’s a Drizzle query straight to Postgres, and Drizzle does not auto-dedupe. cache() is the tool that closes that gap for any non-fetch read. Recall the distinction from earlier: cache() is per-request memoization, holding one result per request that’s discarded when the request ends. That’s a different tool from 'use cache', the cross-request cache that persists between visitors. Here you want per-request, because the invoice read should be shared within this render, not baked into a cache served to everyone.
Two requests hit the invoice page back-to-back: request A renders, then request B renders the same invoice. With getInvoice wrapped in cache(), how many times does Postgres get queried for that invoice across both requests?
cache() dedupes the page’s and generateMetadata’s calls within each request, but the store is thrown away when each request ends.cache() only takes effect once the route is statically optimized.cache() is request-scoped: inside one render it collapses every call to getInvoice(id) into a single round-trip — so the page, generateMetadata, and the OG image all share one read — but it forgets the moment the request ends. Two requests means two stores, hence one query each, two total. Persisting a result across requests (so request B skips the DB) is 'use cache', a different tool entirely. And the dedup has nothing to do with who’s asking — cache() keys on the call’s arguments, not the session.Open Graph and Twitter cards in metadata
Section titled “Open Graph and Twitter cards in metadata”The tab title and the search description are text. The Slack unfurl is a card: a thumbnail image with a title and description, rendered by whatever app the link lands in. That card is driven by Open Graph tags, and X reads its own near-identical twitter: set. Both are fields on the metadata object.
export const metadata: Metadata = { openGraph: { title: 'Invoices', description: 'Create, send, and track invoices.', images: [{ url: '/og/invoices.png', width: 1200, height: 630, alt: 'Acme Invoices' }], type: 'website', }, twitter: { card: 'summary_large_image', title: 'Invoices', description: 'Create, send, and track invoices.', images: ['/og/invoices.png'], },};Two details carry weight. First, the standard Open Graph image size is 1200×630, and you declare width and height explicitly. Some scrapers reject a card whose image dimensions they can’t read, and a card that omits them often gets the image upscaled, so it lands fuzzy. Declaring the real dimensions is cheap insurance. Second, twitter.card takes a few values, and the only two you’ll reach for are summary_large_image, the big hero card that’s the SaaS default, and summary, the small square thumbnail. Use the large one unless you have a reason not to.
One rule sets up everything after this section. A file-based metadata convention overrides the metadata object. When you drop an opengraph-image file into a route segment, which the next section is entirely about, Next wires up the og:image tags from that file automatically: the URL, the width and height, the type, and the alt text from a sibling file. You do not also set metadata.openGraph.images in that case. The file convention owns those tags, so setting both is redundant at best and conflicting at worst. Use the metadata field above when the card image is a fixed asset you point at by URL, and the file convention when the card is generated. They don’t collide, because the file always wins.
One worry is worth putting to rest. Will a scraper actually see a title your generateMetadata computed at request time? Yes. Most scrapers read your <head> tags from the raw HTML response, and Next keeps metadata blocking in the <head> for HTML-only scrapers like Facebook’s facebookexternalhit. It only streams metadata in for bots that execute JavaScript. Your dynamic title and card show up in the unfurl without any extra work on your part.
Generating the card: opengraph-image.tsx
Section titled “Generating the card: opengraph-image.tsx”There are two ways to give a route an OG image, and as elsewhere in this chapter, the default is the plain one you reach for first.
The default: a static opengraph-image.png
Section titled “The default: a static opengraph-image.png”Drop a 1200×630 PNG named opengraph-image.png into the route segment, add a sibling opengraph-image.alt.txt holding the alt text, and you’re done. Next hashes the file, serves it from a stable URL, and wires the og:image tags (dimensions, type, and alt) with zero code. It costs nothing at runtime, because the image is just a static asset. This is the right pick whenever the card doesn’t need per-resource data, such as a marketing page, a settings screen, or any section that shows the same branded card no matter what. A single opengraph-image.png at the app root becomes the brand fallback for every route that doesn’t override it (its place in the full root SEO bundle is the next lesson’s job).
The power-tool: opengraph-image.tsx
Section titled “The power-tool: opengraph-image.tsx”You reach past the static file only when the card must show data that’s specific to the resource, such as the invoice number and the customer name baked into the image itself. For that, the file is a .tsx that generates the PNG. It default-exports a function returning a new ImageResponse(<jsx>, { ... }) from next/og, and three small exports configure the output.
import { ImageResponse } from 'next/og';
import { getInvoice } from '@/db/queries/invoices';
export const alt = 'Invoice card';export const size = { width: 1200, height: 630 };export const contentType = 'image/png';
export default async function Image({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const invoice = await getInvoice(id);
return new ImageResponse( ( <div style={{ display: 'flex', flexDirection: 'column', width: '100%', height: '100%', padding: 80, justifyContent: 'space-between', background: '#0b0b0c', color: '#fafafa' }}> <div style={{ fontSize: 32, opacity: 0.7 }}>Acme</div> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ fontSize: 72 }}>Invoice {invoice?.number}</div> <div style={{ fontSize: 40, opacity: 0.8 }}>{invoice?.customerName}</div> </div> </div> ), { ...size }, );}The import is ImageResponse from next/og. The three top-level exports configure the generated image: alt is the alt text (colocating it as an export const keeps it next to the code instead of in a separate .txt), size declares the 1200×630 canvas, and contentType names the output format. This default-exported Image function is one of the framework’s sanctioned default exports, dictated by the App Router exactly like page.tsx.
import { ImageResponse } from 'next/og';
import { getInvoice } from '@/db/queries/invoices';
export const alt = 'Invoice card';export const size = { width: 1200, height: 630 };export const contentType = 'image/png';
export default async function Image({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const invoice = await getInvoice(id);
return new ImageResponse( ( <div style={{ display: 'flex', flexDirection: 'column', width: '100%', height: '100%', padding: 80, justifyContent: 'space-between', background: '#0b0b0c', color: '#fafafa' }}> <div style={{ fontSize: 32, opacity: 0.7 }}>Acme</div> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ fontSize: 72 }}>Invoice {invoice?.number}</div> <div style={{ fontSize: 40, opacity: 0.8 }}>{invoice?.customerName}</div> </div> </div> ), { ...size }, );}This is the Next 16 change to remember. The image function receives params as a Promise, mirroring generateMetadata, so you await it to read the [id]. In older Next, params here was a plain object; in 16 it’s a Promise, like every other route input.
import { ImageResponse } from 'next/og';
import { getInvoice } from '@/db/queries/invoices';
export const alt = 'Invoice card';export const size = { width: 1200, height: 630 };export const contentType = 'image/png';
export default async function Image({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const invoice = await getInvoice(id);
return new ImageResponse( ( <div style={{ display: 'flex', flexDirection: 'column', width: '100%', height: '100%', padding: 80, justifyContent: 'space-between', background: '#0b0b0c', color: '#fafafa' }}> <div style={{ fontSize: 32, opacity: 0.7 }}>Acme</div> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ fontSize: 72 }}>Invoice {invoice?.number}</div> <div style={{ fontSize: 40, opacity: 0.8 }}>{invoice?.customerName}</div> </div> </div> ), { ...size }, );}Read the invoice through the same cache()-wrapped getInvoice the page and generateMetadata already call. That’s the payoff of the dedup section: the page, the metadata, and now the OG image are three consumers of one cached read, so a single request still hits the database once even though three things need the invoice.
import { ImageResponse } from 'next/og';
import { getInvoice } from '@/db/queries/invoices';
export const alt = 'Invoice card';export const size = { width: 1200, height: 630 };export const contentType = 'image/png';
export default async function Image({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const invoice = await getInvoice(id);
return new ImageResponse( ( <div style={{ display: 'flex', flexDirection: 'column', width: '100%', height: '100%', padding: 80, justifyContent: 'space-between', background: '#0b0b0c', color: '#fafafa' }}> <div style={{ fontSize: 32, opacity: 0.7 }}>Acme</div> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ fontSize: 72 }}>Invoice {invoice?.number}</div> <div style={{ fontSize: 40, opacity: 0.8 }}>{invoice?.customerName}</div> </div> </div> ), { ...size }, );}The returned JSX is rendered to a PNG. Notice every element uses an inline style object with display: 'flex'. That’s not a stylistic choice; it’s a hard constraint of the rendering engine, covered next. The invoice number and customer name interpolate straight into the layout.
import { ImageResponse } from 'next/og';
import { getInvoice } from '@/db/queries/invoices';
export const alt = 'Invoice card';export const size = { width: 1200, height: 630 };export const contentType = 'image/png';
export default async function Image({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const invoice = await getInvoice(id);
return new ImageResponse( ( <div style={{ display: 'flex', flexDirection: 'column', width: '100%', height: '100%', padding: 80, justifyContent: 'space-between', background: '#0b0b0c', color: '#fafafa' }}> <div style={{ fontSize: 32, opacity: 0.7 }}>Acme</div> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ fontSize: 72 }}>Invoice {invoice?.number}</div> <div style={{ fontSize: 40, opacity: 0.8 }}>{invoice?.customerName}</div> </div> </div> ), { ...size }, );}That colocated export const alt feeds the card’s alt attribute. Keeping it in this file, beside the layout it describes, is why we prefer it over a separate sibling .txt for the dynamic case.
Satori’s constraints shape how you build the card
Section titled “Satori’s constraints shape how you build the card”That JSX doesn’t render the way a React component does. It runs through Satori , an engine that turns a subset of JSX and CSS into an SVG and then a PNG. The subset is deliberately narrow, and you have to design within it:
- Flexbox only.
display: 'flex'works;display: 'grid'does not. You lay everything out with flex containers. - A limited CSS subset. Many properties work and many don’t. There’s no cascade, only inline
styleobjects on each element. - No Tailwind classes resolved. The
className-and-utility workflow you use everywhere else doesn’t apply here, so styles are inline objects.
Work with that narrowness rather than against it. The design surface is constrained so the render stays fast and deterministic, which is what you want for a small, fixed-size graphic rather than an app. The common mistake is to grab a real component you already built, drop it in, and hit one unsupported feature after another. Design the card fresh, in flexbox, with inline styles.
Fonts, and the runtime that actually runs this
Section titled “Fonts, and the runtime that actually runs this”To render the card in your brand typeface instead of a default, you read the font file and hand its bytes to ImageResponse through its fonts option.
import { readFile } from 'node:fs/promises';import { join } from 'node:path';
const interSemiBold = await readFile(join(process.cwd(), 'assets/Inter-SemiBold.ttf'));
return new ImageResponse(jsx, { ...size, fonts: [{ name: 'Inter', data: interSemiBold, weight: 600 }],});That snippet corrects a stale claim you’ll see repeated all over the internet: “OG image generation runs on the Edge, so you can’t use Node APIs.” In Next 16 that’s no longer true. Generated OG images are statically optimized by default, which means Next renders them at build time and caches the result. The official examples read local font files with node:fs/promises and process.cwd(), and those work because the file is a Node-capable build-time route handler. So you can read from the filesystem here. Bundle a small subset of the font, just the weights and glyphs the card uses, to keep the render quick.
The whole point is that the card lives next to the page, colocated in the route segment. That’s the file-convention mental model. Here are both shapes side by side.
Directoryapp/
Directory(app)/
Directoryinvoices/
Directory[id]/
- page.tsx
- opengraph-image.png the card image
- opengraph-image.alt.txt the alt text
- not-found.tsx
Directoryapp/
Directory(app)/
Directoryinvoices/
Directory[id]/
- page.tsx
- opengraph-image.tsx generates the card per invoice
- not-found.tsx
Keeping the card fresh: caching the OG output
Section titled “Keeping the card fresh: caching the OG output”You met the rendering model for this file in passing: the OG image is a special route handler, and under Cache Components it’s statically optimized by default, built once and cached on the CDN, unless it reads a request API or uncached data. For the invoice card, the only thing it reads is the cache()-wrapped getInvoice, a deterministic read keyed by id. So the card stays static and cacheable, which is exactly what you want for a graphic that’s identical every time the same invoice is requested.
A question follows from this. When an invoice is edited or archived, both the page and its cached card are now stale, and both have to refresh together, or the unfurl shows an old invoice number. This is the tag-driven invalidation you’ve already built, applied to one more cached output. You tag the cacheable unit, and the mutation invalidates the tag.
import { invoiceTags } from '@/lib/tags';
export const getCachedInvoice = async (orgId: string, id: string) => { 'use cache'; cacheTag(invoiceTags.record(orgId, id)); return db.query.invoices.findFirst({ where: eq(invoices.id, id) });};This is the same tag machinery from earlier, pointed at the OG output, so you don’t need to re-derive the caching model. The shape is straightforward: the cacheable unit carries cacheTag(invoiceTags.record(orgId, id)), a tag from the tags.ts helper rather than an inline literal, and the mutation invalidates that exact tag. A Server Action that edits the invoice calls updateTag(...) for read-your-writes freshness within the request. An eventual update arriving from a webhook calls revalidateTag(tag, 'max'), and in Next 16 that second argument is mandatory, since the single-argument form won’t type-check. Invalidate the tag, and the page and the card refresh as one. Notice that orgId rides in as a parameter. A cross-request 'use cache' boundary mustn’t capture request-scoped values, or it would bake one tenant’s key into a cache shared with everyone.
There’s one Cache Components interaction with generateMetadata worth knowing, and the next section’s rule follows directly from it. If generateMetadata reads request data like cookies() or headers() while the page is otherwise fully prerenderable, Next raises an error and makes you declare the intent. The metadata and the page now disagree about whether the route is static, and the framework won’t guess which you meant. To resolve it, make the intent explicit: if the metadata depends on external but non-request data, mark the read 'use cache'.
const getCardData = async (id: string) => { 'use cache'; // ...read external, non-request data};Notice the limit on that escape hatch: it only applies when the data is external and non-request. Reading the session in metadata is a different case entirely, and the next section explains why you never do it.
What never goes in an OG card
Section titled “What never goes in an OG card”This rule is a security concern, not a style tip. Never personalize an OG card, or any shared metadata, from the session.
Think about who can see an OG card. It’s rendered into a URL, and that URL can be opened by anyone who has the link. Slack unfurls it on behalf of a whole channel. A logged-out colleague who clicks it sees the same card. A bot fetches it with no cookies at all. If you derive the card from cookies() or the signed-in user, say by stamping “Prepared by Dana Okafor” onto it, you’ve leaked one user’s identity into a surface that anyone with the link can view. And per the Cache Components rule you just saw, reading the session also forces the metadata dynamic, which kills the static card. The privacy hazard and the performance hit point the same direction.
The rule comes down to one sentence: an OG card’s data comes only from params plus the resource read, never from who’s asking. The invoice number and customer name are properties of the invoice, identical for every viewer, so they’re safe. The session describes the requester, and it has no place in a shared graphic. That same params-only read is also what keeps the card static and shareable in the first place, so the safety rule and the performance property are two sides of the same thing.
Each claim is about the metadata and OG surface you just learned. Mark each statement True or False.
A single file can export both metadata and generateMetadata, letting it declare static defaults and override them dynamically.
metadata when the values are known up front, and generateMetadata when they depend on the resource.When you add a static opengraph-image.png to a route, you should leave metadata.openGraph.images unset.
metadata object. The file convention wires the og:image tags — URL, dimensions, type, alt — automatically; setting the field too is redundant at best, conflicting at worst.An opengraph-image.tsx can read a local font file with node:fs/promises.
process.cwd(). The “Edge-only, no Node APIs” claim is stale.An invoice’s OG card may include the signed-in user’s name to personalize the share.
params plus the resource read.Reveal card-by-card review
Where to go deeper
Section titled “Where to go deeper”The metadata and OG surface is large, and these are the references worth keeping open while you build a card.
The full field reference and the OG-image guide in one page.
Every export the file accepts, plus the v16 params-is-a-Promise note and the alt conventions.
Iterate on a card's flexbox layout live in the browser before wiring it into the route.
The canonical list of which CSS properties render — the reference for the flexbox-only subset.