Skip to content

Page metadata

Titles, descriptions and Open Graph tags, exported from the page.

Updated View as Markdown

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

src/app/about/page.tsxtsx
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:

src/app/posts/[slug]/page.tsxtsx
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.

It never holds the page up. A route with a generateMetadata — on the page or on any layout above it — streams its metadata like any other read: the page paints at once, and the <title> and meta tags arrive when the lookup finishes. A route with only static metadata has it in the first bytes, as there is nothing to wait for.

A search engine or a link-preview scraper is answered once the whole page has rendered, so what it reads has the metadata in <head> — see Errors for how it is recognised.

A layout can export generateMetadata too, as in Next. Its answer stands for the segments beneath it: a category named in its layout is the title of every page under it that has no title of its own, and the template of the layout above it still applies.

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:

src/app/layout.tsxtsx
export const metadata: Metadata = {
  title: {
    template: '%s | Acme',
    default: 'Acme',
  },
  description: 'The Acme dashboard.',
};
src/app/settings/page.tsxtsx
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"> — a string, or the object Next takes: { index: false, follow: false } is noindex, nofollow; googleBot is <meta name="googlebot">
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.

Charset and viewport

Every document gets <meta charSet="utf-8"> and <meta name="viewport" content="width=device-width, initial-scale=1">, the way Next wrote them without being asked — a layout copied from a Next app never wrote the tag, and a page without a viewport meta is the desktop layout on a phone. A layout that renders either tag itself is left alone.

To change the viewport, export it, on a layout or a page, in Next’s shape:

import type { Viewport } from '@rsc-kit/core/metadata'

export const viewport: Viewport = {
  width: 'device-width',
  initialScale: 1,
  maximumScale: 1,
  userScalable: false,
  themeColor: [
    { media: '(prefers-color-scheme: light)', color: '#ffffff' },
    { media: '(prefers-color-scheme: dark)', color: '#000000' },
  ],
  colorScheme: 'dark light',
}

Layouts outer to inner, then the page, merged per key. themeColor and colorScheme become their own meta tags; a themeColor here wins over the web manifest’s.

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.

On an iPhone’s home screen

appleWebApp is Next’s shape — capable, title, statusBarStyle, startupImage — and renders the same tags. Launch screens are simpler as files named by their size; see Splash screens.

src/app/layout.tsxtsx
export const metadata: Metadata = {
  appleWebApp: { capable: true, title: 'Orders', statusBarStyle: 'black-translucent' },
}

The share card

src/app/layout.tsxtsx
export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  openGraph: {
    siteName: 'Example',
    type: 'website',
  },
  twitter: {
    card: 'summary_large_image',
    site: '@example',
  },
};
src/app/posts/[slug]/page.tsxtsx
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 place

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

src/app/manifest.tsts
export default {
  name: 'Orders',
  themeColor: '#0b0b0c',
  // icons omitted — the build uses the icon-*.png beside this file
} satisfies WebManifest

Listing them as well would be the same set written twice, and the copy that goes stale is the one nobody looks at.

A page’s own share image

The two share images are not only the site’s. Put one in any folder under app/ and it is the card for the pages in that folder and beneath it. The nearest one wins:

src/app/
  opengraph-image.png            every page without a nearer one
  pricing/
    opengraph-image.png          /pricing
  (marketing)/
    laravel/
      opengraph-image.png        /laravel, the group never in the url
  blog/[slug]/
    opengraph-image.png          every post

Nothing is registered and no page is edited: drop the file in. Each kind is looked up on its own, so a twitter-image.png in pricing/ does not take the og:image with it.

A page that names an image in its metadata replaces the site’s the same way, rather than adding a second tag behind it. A scraper reads the first og:image it meets, so a second after the site’s would be a card that never changes:

src/app/pricing/page.tsxtsx
export const metadata: Metadata = {
  openGraph: { images: [{ url: '/pricing-card.png', width: 1200, height: 630 }] },
}

The nearer declaration wins, whichever kind it is. An image exported by a page beats a file in a folder above it; a file in pricing/ beats one a layout exports from app/. In the same folder the file wins, as it does in Next. An openGraph that names no images at all leaves the card to the file.

Two things this does not do. openGraph is still replaced whole by the nearer segment’s, as in Next, so a page that sets its own openGraph should repeat the siteName it wants. And the same placement is not yet read for icon or apple-icon: those are the site’s.

Folders that are not part of the url tree are not searched: _private, @slot and (.)intercepted. A new share image is picked up by the dev server on its next restart, which it does by itself.

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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close