UNPKG

@rsc-kit/mcp

Version:

An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.

341 lines (269 loc) • 11.8 kB
# Page metadata > Titles, descriptions and Open Graph tags, exported from the page. 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 ```tsx title="src/app/about/page.tsx" 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: ```tsx 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. :::tip[Custom meta tags go under `other`] ```tsx export const metadata: Metadata = { title: 'Pricing', other: { 'fb:app_id': '1234', 'theme-color': '#111' }, }; ``` The named keys are a closed set on purpose. When any key was legal, a typo like `titel` was accepted in silence — and your editor offered no completions at all. ::: ## Metadata from data When the values depend on what the page loaded, export `generateMetadata`. It receives the route params: ```tsx title="src/app/posts/[slug]/page.tsx" 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. The one exception is the shell of a route that lists no urls, where the params are not known: a `generateMetadata` that reads them is left out of that shell, and the host writes the real values in when it serves the shell for a url — see [Partial prerendering](/guides/ppr). 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: ```tsx title="src/app/layout.tsx" export const metadata: Metadata = { title: { template: '%s | Acme', default: 'Acme', }, description: 'The Acme dashboard.', }; ``` ```tsx title="src/app/settings/page.tsx" 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: ```tsx 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](/guides/pwa#splash-screens). ```tsx title="src/app/layout.tsx" export const metadata: Metadata = { appleWebApp: { capable: true, title: 'Orders', statusBarStyle: 'black-translucent' }, } ``` ## The share card ```tsx title="src/app/layout.tsx" export const metadata: Metadata = { metadataBase: new URL('https://example.com'), openGraph: { siteName: 'Example', type: 'website', }, twitter: { card: 'summary_large_image', site: '@example', }, }; ``` ```tsx title="src/app/posts/[slug]/page.tsx" 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. :::caution[`metadataBase` is not optional in production] A share-card scraper needs an **absolute** image url. Facebook, Slack and LinkedIn all refuse a relative one, silently — the link unfurls with no image and nothing says why. Set `metadataBase` on the root layout and every relative `url`, image and icon below it is made absolute. Without it, an `opengraph-image.png` in `app/` goes out as `/_app/opengraph-image.png`, which works in a browser and nowhere else. It is the same name as Next's, so a port carries it across unchanged. ::: `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: ```tsx // 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 ```text 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](/guides/offline/)'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: ```ts title="src/app/manifest.ts" 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. ### 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. :::note[Set `metadataBase`] The image is emitted as `/_app/opengraph-image.png`, which a scraper will not fetch. With `metadataBase` on the root layout it goes out absolute — see [the share card](#the-share-card) above. ::: ### 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](/guides/api-routes/) that returns one, cached, and the `openGraph.images` url points at it. Neither touches the build.