Metadata and dynamic OG cards
Drive a page's title, search description, and social share card from the Next.js Metadata API, generateMetadata, and generated Open Graph images.
A teammate adds an invoice detail page at app/(app)/invoices/[id]/page.tsx.
Routing works, but three things are missing.
The browser tab shows a bare URL instead of the invoice number, a crawler finds no description, and pasting the link into Slack produces a blank unfurl: no logo, no invoice number, no customer name.
All three come from one surface, the page’s metadata: data about the page, authored where the page lives.
Export a static metadata object when the values are known up front, a generateMetadata function when they depend on the resource, and an opengraph-image file when the preview is a picture.
The static metadata constant
Section titled “The static metadata constant”The default mechanism is a plain exported constant.
Add it to any layout.tsx or page.tsx, and Next renders the matching <head> tags for you, with 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.',};Note the import type.
Metadata is a type, never a runtime value, so it imports on its own type-only line; the project’s verbatimModuleSyntax setting makes a plain import here a lint error.
The object’s keys map one-to-one onto the tags Next emits: title becomes <title>, description becomes <meta name="description">.
Metadata merges down the tree
Section titled “Metadata merges down the tree”Next evaluates metadata from the root layout down to the leaf page and merges the objects.
The root sets brand-wide defaults; each page below overrides only its page-specific keys.
Set description once at the root as a fallback, and a page replaces it with something sharper, so you never repeat shared values.
The merge is shallow: a key set lower in the tree replaces the same key higher up, with no deep merge.
That is fine for flat values like title and description.
But openGraph is an object, so a page that sets any field inside it replaces the entire parent openGraph, not just the field it changed.
The fix: pull the shared OG fields into a constant and spread them into each page’s openGraph, so page-level fields layer on top.
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 title doing something special.
The page set title: 'Invoice INV-1042', yet the tab reads Invoice INV-1042 — Acme.
That suffix comes from the title template.
title: the template and the default
Section titled “title: the template and the default”You rarely want a page to spell out its full tab title.
You want every page suffixed with the product name, like Invoices — Acme and Settings — Acme, without each page restating the 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. template is the pattern, and %s is the slot each child title drops into. default is the title used when a page supplies none of its own; setting a template makes it required, since a route with no child title, like the root itself, still needs something to render. The template applies to a segment’s descendants, never the defining segment, so the root renders default, not 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 without the page typing the suffix. To escape the template, like a landing page that should read just Acme, set title: { absolute: 'Acme' } instead of a string.
The six fields a web app actually uses are title, description, openGraph, twitter, alternates.canonical, and metadataBase; metadata accepts many more, which you add the day a specific need shows up.
robots and icons come in the next lesson with the rest of the root SEO bundle.
metadataBase and the canonical URL
Section titled “metadataBase and the canonical URL”Several metadata fields hold absolute URLs: the canonical tag, the Open Graph image, alternate-language links.
metadataBase sets the origin once
Section titled “metadataBase sets the origin once”Scrapers and search engines require these URLs to be absolute (https://app.acme.com/invoices), not relative (/invoices).
Set the origin once in metadataBase and write relative paths below it.
This fills the export const metadata placeholder the fonts lesson left in app/layout.tsx.
export const metadata: Metadata = { metadataBase: new URL('https://app.acme.com'), // ...title template, etc.};Next composes each relative URL below it into an absolute one against that base.
A relative URL without a metadataBase is a build error.
Leave metadataBase unset and Next infers the origin: your Vercel deployment URL (VERCEL_URL) in production, or localhost:3000 in development.
Pin it anyway, because VERCEL_URL is a per-deployment hostname, a different subdomain for every preview build.
Pinning it to the production origin keeps your canonical and OG URLs pointed at the real one.
alternates.canonical consolidates duplicate URLs
Section titled “alternates.canonical consolidates duplicate URLs”The same 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 distinct pages and splits your ranking signal across them.
The canonical URL is the fix: a tag naming the one URL you want indexed.
export const metadata: Metadata = { alternates: { canonical: '/invoices', },};It is relative because metadataBase composes it into the absolute canonical URL.
Set it on any page where searchParams can spin up duplicate 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 is a constant, so it can’t know that this page renders INV-1042, whose number lives in the database keyed by the [id] in the URL.
When the title, description, or card depends on the resource the page renders, export generateMetadata instead: an async function that returns the same Metadata object, but built from a database read you await first.
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 receives the same params as the page. 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 a Promise of { id: string }, with no hand-written prop type.
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) 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. notFound() throws to the route’s not-found.tsx boundary and stops rendering. Running this against the same read the page uses keeps the page and its <head> in agreement on whether the invoice exists.
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 number, the description names the customer, and openGraph carries a card-specific title. Setting openGraph triggers the shallow-merge rule, so it replaces the root’s openGraph entirely and any shared OG field must be spread back in here.
One rule trips people into a build error: you cannot export both metadata and generateMetadata from the same file.
Pick the constant when it’s static, the function when it depends on the resource.
Reading the invoice once with cache()
Section titled “Reading the invoice once with cache()”generateMetadata reads the invoice to build the <head>, then the page reads the same invoice to build the body: two database round-trips for one page load, multiplied across a list of links each prefetching their metadata.
The fix is a tool you already have: React’s cache().
Wrap the read once, and every call within the same request shares one result.
Both generateMetadata and the page call the wrapped function, so the first call hits the database and the rest reuse its result.
export const getInvoice = async (id: string) => { const invoice = await db.query.invoices.findFirst({ where: eq(invoices.id, id), }); return invoice ?? null;};Two DB hits per page. A plain async function runs its body on every call. 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, shared. cache() memoizes the function for the request. generateMetadata calls it and Postgres is queried once, then the page calls it and gets the stored result. One round-trip serves both.
Why not assume the framework handles this?
With fetch, Next deduplicates identical calls within a render automatically.
But this read is a Drizzle query straight to Postgres, and Drizzle does not auto-dedupe.
cache() closes that gap for any non-fetch read.
It’s per-request memoization, discarded when the request ends, unlike 'use cache', which persists across requests.
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 search description are plain text, but a Slack unfurl is a card: a thumbnail, title, and description rendered by whatever app the link lands in.
That card comes from 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'], },};The standard Open Graph image is 1200×630, and you declare width and height explicitly: some scrapers reject a card whose dimensions they can’t read, and one that omits them often upscales the image and lands fuzzy.
twitter.card defaults to summary_large_image, the big hero card you want; the only other value you’d reach for is summary, a small square thumbnail.
A file-based convention overrides this object: drop an opengraph-image file into a route segment and Next wires up the og:image tags (URL, width, height, type, alt) from it.
Do not also set metadata.openGraph.images; setting both conflicts, since the file owns those tags.
Use the metadata field above for a fixed asset you point at by URL, the file convention when the card is generated.
Scrapers read <head> tags from the raw HTML response, including titles generateMetadata computed at request time.
Next keeps metadata blocking in the <head> for HTML-only scrapers like Facebook’s facebookexternalhit, streaming it in only for bots that execute JavaScript.
Two ways to give a route an OG image
Section titled “Two ways to give a route an OG image”A route gets its OG image two ways, and the default is the plain one.
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 with 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 no code.
Since the image is a static asset, it costs nothing at runtime.
Reach for it whenever the card needs no per-resource data: a marketing page, a settings screen, anything that shows the same branded card regardless of content.
A single opengraph-image.png at the app root becomes the brand fallback for every route that doesn’t override it.
The power-tool: opengraph-image.tsx
Section titled “The power-tool: opengraph-image.tsx”Reach past the static file only when the card must show data specific to the resource, such as the invoice number and customer name baked into the image.
The file is then a .tsx that generates the PNG: it default-exports a function returning a new ImageResponse(<jsx>, { ... }) from next/og, plus three exports that 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 }, );}Import ImageResponse from next/og. The three exports configure the generated image: alt is the alt text (an export const keeps it beside the code instead of in a separate .txt), size declares the 1200×630 canvas, and contentType names the output format. The App Router dictates the default-exported Image function, 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 }, );}In Next 16 the image function receives params as a Promise, mirroring generateMetadata, so you await it to read the [id].
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 call. Three consumers share one cached read, so a single request hits the database once.
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 JSX renders to a PNG. Every element uses an inline style object with display: 'flex', a hard constraint of the rendering engine rather than a style choice. The invoice number and customer name interpolate straight into the layout.
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 like a React component. It runs through Satori , which turns a subset of JSX and CSS into an SVG and then a PNG. The subset is deliberately narrow, and you design within it:
- Flexbox only.
display: 'flex'works;display: 'grid'does not. Lay everything out with flex containers. - A limited CSS subset. Many properties work and many don’t, with no cascade, only inline
styleobjects on each element. - No Tailwind. The
className-and-utility workflow doesn’t apply here, so styles are inline objects.
The narrow surface keeps the render fast and deterministic, which is what a small, fixed-size graphic wants. Don’t drop in a component you already built and fight 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, 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 }],});You may have read that OG image generation runs on the Edge and can’t use Node APIs.
In Next 16 that’s stale.
Generated OG images are statically optimized by default: Next renders them at build time and caches the result.
Because the route handler runs at build time and is Node-capable, you read local font files with node:fs/promises and process.cwd(), as the official examples do.
Bundle a small subset of the font, just the weights and glyphs the card uses, to keep the render quick.
The card lives next to the page, colocated in the route segment. Here are both file 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
Caching and invalidating the OG card
Section titled “Caching and invalidating the OG card”The OG image is a 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.
The invoice card reads only the cache()-wrapped getInvoice, keyed by id, so it stays static.
When an invoice is edited or archived, the page and its cached card go stale and must refresh together. This is the tag-driven invalidation you’ve already built: 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) });};The tag comes from the tags.ts helper, not an inline literal.
A Server Action that edits the invoice calls updateTag(...) for read-your-writes freshness within the request; an update from a webhook calls revalidateTag(tag, 'max'), whose second argument is mandatory in Next 16.
orgId rides in as a parameter because a cross-request 'use cache' boundary mustn’t capture request-scoped values, or it would bake one tenant’s key into a shared cache.
generateMetadata interacts with Cache Components.
If it reads request data like cookies() or headers() while the page is otherwise prerenderable, the metadata and page disagree about whether the route is static, so Next raises an error.
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};That escape hatch is for external, non-request data only. Reading the session in metadata is a different case, covered next.
What never goes in an OG card
Section titled “What never goes in an OG card”Never personalize an OG card, or any shared metadata, from the session. This is a security rule, not a style tip.
An OG card renders into a URL that anyone with the link can open: Slack unfurls it for a whole channel, a logged-out colleague sees the same card, a cookieless bot fetches it.
Derive it from cookies() or the signed-in user, say by stamping “Prepared by Dana Okafor” onto it, and you leak one user’s identity into a surface anyone with the link sees.
Reading the session also forces the metadata dynamic and kills the static card, so the privacy hazard and the performance hit point the same way.
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; the session describes the requester and has no place in a shared graphic.
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”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.