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. 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 otfw build --ssg.
A dynamic route without getStaticPaths produces no pre-rendered HTML, but generateMetadata still runs per request under otfw serve / SSR — so those pages get their <head> at request time, including query params.
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 otfw.config.js (or pass --base-url at build time). In a plain SPA or fullstack project, export a plain config object:
// otfw.config.js export default { site: { url: "https://example.com" }, };
In a @opentf/web-docs project, wrap it with defineDocsConfig instead:
// otfw.config.js import { defineDocsConfig } from "@opentf/web-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 |
|---|---|
otfw build --ssg | Each pre-rendered .html file gets a resolved <head> per route |
otfw serve / SSR adapters | <head> is rendered per request |
Plain CSR (otfw build) | The single index.html shell gets the root layout's site-wide metadata — favicon/other links, description, and Open Graph site defaults. Per-page title, canonical, and generateMetadata are route-specific and need --ssg or otfw serve |
After first paint, SPA navigations via <Link> do not re-run metadata resolution. Crawlers and social previews rely on the document you shipped for that URL — use SSG or SSR when SEO and share cards matter.
MDX and docs pages
In @opentf/web-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 web-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 — OTF Web", description: "A zero-VDOM framework.", openGraph: { siteName: "OTF Web", type: "website" }, }; // app/page.jsx export const metadata = { title: { absolute: "OTF Web — 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