Titles and meta tags are exported by the page that needs them. There is no
document template to edit and nothing to register — React hoists the resulting
elements into <head>, so they stream with the page rather than waiting for
it.
Static metadata
export const metadata: Metadata = {
title: 'About us',
description: 'Learn more about our team.',
keywords: ['about', 'team', 'company'],
openGraph: {
title: 'About us',
description: 'Learn more about our team.',
},
};
export default function AboutPage() {
return <h1>About us</h1>;
}Metadata is imported from the package:
import type { Metadata } from '@rsc-kit/core/metadata';It used to be ambient, written into your project by the build. An import resolves as soon as dependencies are installed, where a generated file does not exist until something has been built once — so a freshly cloned project no longer reports an error on every page it has not built yet.
Metadata from data
When the values depend on what the page loaded, export generateMetadata. It
receives the route params:
import { findPost } from '../../../data';
export const generateMetadata: GenerateMetadata<{ slug: string }> = async ({ params }) => {
const { slug } = await params;
const post = await findPost(slug);
return {
title: post.title,
description: post.excerpt,
openGraph: { title: post.title, description: post.excerpt, images: post.cover },
};
};
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await findPost(slug);
return <h1>{post.title}</h1>;
}It receives the same params and searchParams a page does, so there is one
shape to learn rather than two. Unlike a page’s, these always resolve — a
<title> has no fallback, so there is nothing to suspend into.
Metadata always resolves against the full layout chain, even when the
render is partial. A navigation that replaces one segment still produces the
same <title> a full load would, because the template on the outer layout is
consulted either way.
Title templates
A layout can define the shape every title beneath it takes. %s stands in for
the page’s own title:
export const metadata: Metadata = {
title: {
template: '%s | Acme',
default: 'Acme',
},
description: 'The Acme dashboard.',
};export const metadata: Metadata = { title: 'Settings' };
// <title>Settings | Acme</title>The nearest layout with a template wins, so a nested layout can override the
one above it. A page that exports no title gets the default.
Supported keys
| Key | Becomes |
|---|---|
title |
<title> — a string on a page, a template on a layout |
description |
<meta name="description"> |
keywords |
<meta name="keywords">, joined with commas if you pass an array |
author |
<meta name="author"> |
robots |
<meta name="robots"> |
metadataBase |
nothing itself — it is what makes every relative url below absolute |
openGraph |
<meta property="og:…">, one per field; each image brings its size and alt |
twitter |
<meta name="twitter:…"> |
icons |
<link rel="icon">, apple-touch-icon and so on |
other |
<meta name="…">, or property= for an og:/fb:/article: key |
| anything else | <meta name="…"> |
keywords takes either form — 'react, rsc' and ['react', 'rsc'] produce
the same tag.
The flat spellings — 'og:title', 'twitter:card' — still work and render
correctly, but the structured objects are the ones to write: they are typed,
they take an image object with a width and height, and they are what a Next
app already has.
The share card
export const metadata: Metadata = {
metadataBase: new URL('https://remorva.com'),
openGraph: {
siteName: 'Remorva',
type: 'website',
},
twitter: {
card: 'summary_large_image',
site: '@remorva',
},
};export async function generateMetadata({ params }): Promise<Metadata> {
const post = await findPost((await params).slug);
return {
title: post.title,
openGraph: {
title: post.title,
description: post.excerpt,
url: `/posts/${post.slug}`,
images: [{ url: post.cover, width: 1200, height: 630, alt: post.title }],
},
};
}Layouts merge outward-in, so metadataBase, siteName and the card type are
set once and every page gets them.
og: tags render with property= and twitter: with name=, because that is
what each scraper reads. It is worth stating because it is easy to get
backwards, and a tag with the wrong attribute is not an error — it is a card
that never shows.
Icons
A string, a list, or a categorised object:
// One favicon
export const metadata: Metadata = { icons: '/favicon.ico' };
// Several, with attributes
export const metadata: Metadata = {
icons: [
{ url: '/favicon.ico', sizes: '32x32' },
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
],
};
// By role
export const metadata: Metadata = {
icons: {
icon: '/favicon.ico',
apple: '/apple-touch-icon.png',
other: { rel: 'mask-icon', url: '/safari-pinned-tab.svg', color: '#5bbad5' },
},
};Each descriptor takes url, type, sizes, color, rel, media and
fetchPriority.
How it reaches the document
Build the plugin finds the metadata exports and generates a resolver
Render the resolver runs alongside the page, against the full layout chain
Load React hoists <title> and <meta> into <head> as they are emitted
Navigate the resolved metadata rides on the X-RSC-Meta header, and the
client updates document.title and the tags in placeThe last line is why a title is correct after an SPA navigation without the page having been re-rendered from the root.
Icons and share images
Put them in src/app/ and the build finds them. There is nothing to register
and no layout to edit:
| file | what it becomes |
|---|---|
favicon.ico |
served at /favicon.ico |
icon.png, icon-192.png, icon-512.png |
<link rel="icon">, and the manifest’s icons |
apple-icon.png |
<link rel="apple-touch-icon"> |
opengraph-image.png |
<meta property="og:image"> |
twitter-image.png |
<meta name="twitter:image">, with the large-card type |
The names are Next’s. There is nothing to gain from inventing different ones — an app moving between the two should not have to rename its favicon.
Sizes are read from the filename, so icon-192.png needs no entry anywhere
saying it is 192 pixels. Which is why a manifest usually lists no icons at all:
export default {
name: 'Orders',
themeColor: '#0b0b0c',
// icons omitted — the build uses the icon-*.png beside this file
} satisfies WebManifestListing them as well would be the same set written twice, and the copy that goes stale is the one nobody looks at.
Why app/ and not public/
public/ means “serve this file unchanged”. These are read as well as
served: an icon’s filename decides what goes in the manifest, and its presence
decides what goes in the head. A file whose name the build interprets belongs
with the other files whose names the build interprets.
They are copied into the build output under /_app/, so they never collide
with something you put in public/ yourself. The favicon is the exception —
it is served at /favicon.ico, because browsers ask for that url whatever the
markup says.
Not opengraph-image.tsx
Next also accepts a component by that name and renders it to a PNG at build time. This package does not, on purpose. Rendering an image means bundling a layout engine and a rasteriser — a couple of megabytes of dependencies, and seconds added to every build for every image — for a picture that changes when the design does, which is to say rarely.
Make the image once with whatever you like, and commit the file. If a page
needs a different image per record — a card for each post — that is an
api route that returns one, cached, and the
openGraph.images url points at it. Neither touches the build.