Skip to content
Chapter 34Lesson 6

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 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">.

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.

brand defaults

app/layout.tsx

title.template: '%s — Acme'

metadataBase

openGraph: { siteName }

route group

app/(app)/layout.tsx

inherits — sets nothing

page-specific

app/(app)/invoices/[id]/page.tsx

title: 'Invoice INV-1042'

description

Resolved <head>
<title>Invoice INV-1042 — Acme</title>
<meta name="description" …>
<meta property="og:site_name" content="Acme">
from root layout from the page
Metadata is evaluated root-first and shallow-merged down to the leaf; a child key replaces the parent's. The resolved <head> is the composite — coloured here by which file contributed each tag. Because the merge is shallow, a page that sets any openGraph field replaces the parent's openGraph wholesale.

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.

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.

app/layout.tsx
export const metadata: Metadata = {
title: {
template: '%s — Acme',
default: 'Acme',
},
};
// app/(app)/invoices/page.tsx
export const metadata: Metadata = {
title: 'Invoices',
};
// resolves to: Invoices — Acme

The 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.

app/layout.tsx
export const metadata: Metadata = {
title: {
template: '%s — Acme',
default: 'Acme',
},
};
// app/(app)/invoices/page.tsx
export const metadata: Metadata = {
title: 'Invoices',
};
// resolves to: Invoices — Acme

A 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.

1 / 1

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.

Several metadata fields hold absolute URLs: the canonical tag, the Open Graph image, alternate-language links.

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.

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.

1 / 1

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.

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.

db/queries/invoices.ts
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.

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?

Twice — once per request. cache() dedupes the page’s and generateMetadata’s calls within each request, but the store is thrown away when each request ends.
Once total. The first request caches the row and request B reads it straight from memory without touching the database.
Four times — both consumers in both requests query independently, because cache() only takes effect once the route is statically optimized.
Once per request, but only if request A and request B arrive from the same logged-in user; otherwise the cache can’t be shared.

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.

A route gets its OG image two ways, and the default is the plain one.

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.

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.

1 / 1

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 style objects 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
Brand-fixed card: a PNG plus its alt text, zero runtime cost.

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.

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.

It’s one or the other per file — exporting both is a build error. Reach for static 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.

File-based metadata overrides the 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.

Generated OG images are statically optimized and Node-capable by default in Next 16 — the official examples read fonts off the filesystem with 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.

An OG card is rendered into a URL anyone with the link can open — deriving it from the session leaks one user’s data to everyone and forces the metadata dynamic. Card data comes only from params plus the resource read.

References worth keeping open while you build a card.