Metadata & SEO
Pages and layouts export metadata — a plain-data object (or a generateMetadata function) that the toolchain turns into <head> tags during Static Generation and Server rendering, and on client navigation. Layout defaults merge down the folder tree; each page overrides what it needs.
Static metadata
Export a metadata object from any page.jsx, page.mdx, or layout.jsx:
export const metadata = { title: "About", description: "What we build and why.", canonical: "/about", };
Site-wide defaults belong in the root app/layout.jsx so child routes inherit them:
export const metadata = { titleTemplate: "%s — My App", description: "Default description when a page omits one.", openGraph: { siteName: "My App", type: "website" }, links: [{ rel: "icon", href: "/favicon.svg" }], };
A page's title string is wrapped by an inherited titleTemplate ("%s — My App" → About — My App). To keep a bespoke title (for example on a homepage), use an absolute title:
export const metadata = { title: { absolute: "My App — native-first web apps" }, };
Dynamic metadata
For routes with params or query strings, export generateMetadata. It receives { params, query } and its return value wins over static metadata on the same file:
export function getStaticPaths() { return [{ params: { id: "1" } }, { params: { id: "2" } }]; } export function generateMetadata({ params }) { return { title: `Post ${params.id}`, description: `Article ${params.id}.`, canonical: `/post/${params.id}`, }; }
Use generateMetadata on dynamic routes when each path needs its own title, description, or share image. Pair it with getStaticPaths when you pre-render those paths with an esdev prerender target.
A dynamic route without getStaticPaths produces no pre-rendered HTML, but generateMetadata still runs per request through a request renderer / SSR — so those pages get their <head> at request time, including query params.
Client navigation also resolves metadata and updates the document head. Old route metadata is removed while styles, executable scripts and viewport settings remain. Hydrated pages retain their server-rendered head until the next navigation. Prerendering marks generated tags automatically. In a custom SSR renderer, use renderHead(metadata, { path, baseUrl, managed: true }) so navigation can also remove custom meta tags and links from the previous page.
To read metadata inherited from parent layouts, await the second argument. It contains the resolved parent metadata, before this file's own metadata is merged:
export async function generateMetadata({ params }, parent) { const inherited = await parent; return { title: `Post ${params.id}`, openGraph: { image: inherited.openGraph?.image ?? "/default-share.png" }, }; }
Merge order
Metadata resolves from least- to most-specific:
Each layout in the chain (root → leaf): static
metadata, thengenerateMetadataThe page: static
metadata, thengenerateMetadata
Nested objects openGraph, twitter, and robots deep-merge one level — a page can set openGraph.image without dropping a layout's openGraph.siteName.
Fields
| Field | Emits | Notes |
|---|---|---|
title | <title> | String (wrapped by titleTemplate) or { absolute: "…" } |
titleTemplate | — | Layout-only pattern with %s (e.g. "%s — Brand") |
description | <meta name="description"> | Also feeds OG/Twitter when those omit their own |
canonical | <link rel="canonical"> | Relative paths become absolute when site.url is set |
robots | <meta name="robots"> | String ("noindex, nofollow") or { index, follow, noarchive, … } |
openGraph | og:* tags | title, description, type, url, image, siteName |
twitter | twitter:* tags | card, title, description, image — falls back to OG. card auto-upgrades to summary_large_image when an image exists and card is omitted |
jsonLd | <script type="application/ld+json"> | Object or pre-serialized string |
meta | Extra <meta> tags | [{ name | property | httpEquiv, content }] |
links | <link> tags | [{ rel, href, type?, hreflang? }] — favicons, alternates, feeds, etc. |
Open Graph and Twitter images are made absolute against site.url when the path is relative (/og.png → https://example.com/og.png).
Robots and JSON-LD
export const metadata = { robots: { index: false, follow: true }, jsonLd: { "@context": "https://schema.org", "@type": "WebSite", name: "My App", }, };
Escape hatch tags
Anything not covered by the table above goes through meta and links:
export const metadata = { meta: [{ name: "theme-color", content: "#0a0a0a" }], links: [ { rel: "alternate", type: "application/rss+xml", href: "/blog/rss.xml" }, ], };
Site URL and absolute links
Set the canonical origin in afw.config.js, read by your prerender script. In a plain SPA or fullstack project, export a plain config object:
// afw.config.js export default { site: { url: "https://example.com" }, };
In a @opentf/afw-docs project, wrap it with defineDocsConfig instead:
// afw.config.js import { defineDocsConfig } from "@opentf/afw-docs"; export default defineDocsConfig({ site: { url: "https://example.com" }, });
The SSG step uses site.url for canonical URLs, og:url, image absolutization, the sitemap, and blog feeds. See Configuration and Production Build.
When site.url is omitted, canonical and OG URLs stay relative — fine for local preview, not ideal for production SEO.
When metadata is applied
| Mode | <head> behavior |
|---|---|
| Configured prerender build | Each pre-rendered .html file gets a resolved <head> per route |
| App-provided SSR renderer / adapters | <head> is rendered per request |
Plain CSR (esdev build) | Starts with the HTML entry head, then resolves route metadata on mount and navigation |
Client navigation updates the browser head. Use SSG or SSR to include route metadata in the initial HTML for crawlers and social previews.
MDX and docs pages
In @opentf/afw-docs projects, MDX frontmatter sets sidebar labels and feeds the same metadata pipeline as export const metadata:
--- title: Installation description: Scaffold a project and start the dev server. ---
For live JSX routes, prefer export const metadata or generateMetadata on the page module. See afw-docs → Markdown.
Example — layered defaults
Root layout brands every page; the homepage opts out; a dynamic post sets its own canonical:
// app/layout.jsx export const metadata = { titleTemplate: "%s — AFW", description: "A zero-VDOM framework.", openGraph: { siteName: "AFW", type: "website" }, }; // app/page.jsx export const metadata = { title: { absolute: "AFW — The native-first framework" }, canonical: "/", }; // app/post/[id]/page.jsx export function generateMetadata({ params }) { return { title: `Post ${params.id}`, canonical: `/post/${params.id}`, }; }
Related
Pages & Layouts — where metadata exports live
Dynamic Routes —
paramsingenerateMetadataStatic Generation — pre-rendered HTML + head
Configuration —
site.urland build pluginsTroubleshooting — hydration and SEO alignment issues