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:

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:

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

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

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

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

  1. Each layout in the chain (root → leaf): static metadata, then generateMetadata

  2. The page: static metadata, then generateMetadata

Nested objects openGraph, twitter, and robots deep-merge one level — a page can set openGraph.image without dropping a layout's openGraph.siteName.

Fields

FieldEmitsNotes
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, … }
openGraphog:* tagstitle, description, type, url, image, siteName
twittertwitter:* tagscard, 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
metaExtra <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

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

JSX
export const metadata = {
  meta: [{ name: "theme-color", content: "#0a0a0a" }],
  links: [
    { rel: "alternate", type: "application/rss+xml", href: "/blog/rss.xml" },
  ],
};

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:

JavaScript
// afw.config.js
export default {
  site: { url: "https://example.com" },
};

In a @opentf/afw-docs project, wrap it with defineDocsConfig instead:

JavaScript
// 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 buildEach 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
Initial HTML for crawlers

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:

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

JSX
// 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}`,
  };
}
Last updated on
Edit this page