The Next.js App Router metadata API has a property that makes it dangerous in production: nearly every failure mode is silent. A missing metadataBase does not error; it produces relative Open Graph image URLs that resolve against whatever host a crawler happens to hit. A page that exports title instead of using the root template does not warn; it quietly drops your site name from the tab. An og:image written as /og.png in a page-level export does not break the build; it emits a relative URL into markup where the Open Graph spec and most crawlers expect an absolute one.
The checklist for a public App Router page is therefore not “did you export metadata.” It is whether every field that silently degrades has been forced through a boundary that refuses bad input at build time. The Hraness answer to that is a typed metadata contract: one function that takes a validated origin and owned paths, and produces the complete Metadata object (canonical, Open Graph, Twitter, robots) or throws before the page ever renders.
where app router metadata breaks
Four failure modes account for most of the damage on real sites.
Relative social images. The Open Graph image is the canonical example. Next.js lets you write openGraph.images as a relative path, and metadataBase is what turns it absolute. If metadataBase is unset in the root layout, or set from an environment variable that is missing in a preview deployment, the emitted <meta property="og:image"> is relative. Crawlers and link unfurlers are not required to resolve it, and several do not. The page ships, the card does not, and nothing in the build log mentions it.
Title template drift. The root layout pattern is title: { default, template }, where the template wraps leaf titles with the site name. A leaf that exports title: "some page" as a bare string bypasses the template and loses the site name entirely. A leaf that re-exports a full Metadata object without spreading the right fields can drop alternates, robots, or social images inherited from the layout. The merge is shallow per-field, and the fields do not warn when they disappear.
Canonical confusion. alternates.canonical is supposed to be the absolute, self-referential URL of the page. It is easy to emit relative, easy to emit with a trailing slash that differs from the route’s normalization, and easy to forget on nested routes entirely, in which case two representations of the same page (parameterized URLs, preview hosts, .md siblings) compete as duplicates instead of consolidating.
Robots expressed as a string, not a decision. <meta name="robots"> values like index,follow are usually hand-written and almost never audited against what the route actually wants. The interesting part is not index; it is noindex on routes that are reachable but must never be discovered, and the googleBot extensions (max-image-preview, max-snippet, max-video-preview, noarchive, nosnippet) that control how much of a page may be excerpted.
the typed boundary
The shared @hraness/web-discovery package turns the checklist into a parse. createPublicSiteMetadata takes a SearchSite (a type with a bare https:// origin literal, a title, a description, and optional social image) and returns a complete Metadata object. Everything that can silently degrade is validated first:
const parsed = new URL(origin);
if (
parsed.protocol !== "https:"
|| parsed.username.length > 0
|| parsed.password.length > 0
|| parsed.pathname !== "/"
|| parsed.search.length > 0
|| parsed.hash.length > 0
) {
throw new RangeError(`Search origins must be bare HTTPS origins; received ${origin}.`);
}
The origin must be a bare HTTPS origin. Credentials, a path, a query string, or a fragment are all RangeErrors at the moment the module initializes, which for a static or statically-generated route is the build. Paths are validated the same way: OwnedPath is a root-relative /${string} that cannot contain a query or fragment, cannot normalize into a different origin, and cannot require URL normalization to remain itself. A canonical URL is then constructed as absoluteWebUrl(origin, path), never concatenated.
What the function emits is complete by construction: metadataBase from the validated origin, title as { default, template } when a template is configured, description, applicationName, alternates.canonical as an absolute URL, alternates.types for feed discovery when a feed path is declared, openGraph with type: "website", url, siteName, locale, and an absolute image at the fixed 1200×630 card size, robots set to an indexable policy, and twitter.card as summary_large_image. There is no field left to remember, because there is no field left optional.
The same package handles the structured-data half of the contract. serializeJsonLd JSON-stringifies the schema and then escapes &, <, >, and the U+2028/U+2029 line separators, so a title that happens to contain </script> cannot break out of its script context. That is the same posture as the metadata contract: the field that can silently corrupt markup is escaped at the boundary, not trusted.
private is a different contract
The important thing about a noindex route is not that it be blocked; it is that its metadata not pretend otherwise. createPrivateSiteMetadata emits the same metadataBase, title, description, and applicationName, but robots is NOINDEX_ROBOTS and both alternates and the entire openGraph/twitter block are absent. A private surface has no canonical URL worth publishing and no social card worth sharing, and the metadata object says so rather than letting defaults leak.
The constants make the two policies readable side by side:
export const INDEXABLE_ROBOTS = {
follow: true,
googleBot: {
follow: true,
index: true,
"max-image-preview": "large",
"max-snippet": -1,
"max-video-preview": -1,
},
index: true,
} as const;
export const NOINDEX_ROBOTS = {
follow: false,
googleBot: {
follow: false,
index: false,
noarchive: true,
nosnippet: true,
},
index: false,
} as const;
The private policy is not a milder indexable policy; it is a different object. follow: false means crawlers that do visit do not use the page to discover others, and noarchive plus nosnippet means the page does not get a cached copy or a snippet in results. That is the correct posture for checkout return pages, design galleries, preview deployments, and any surface that exists to be used rather than found. robots.txt is not a substitute: a disallowed route can still be indexed from inbound links, because the crawler never fetches the page that would have carried the noindex. Page-level noindex requires the page to be fetchable, which is the opposite of disallowing it.
per-page metadata stays inherited
The pattern that keeps the contract honest on real routes is “derive, don’t restate.” A lesson page under /reference does not rebuild its metadata from scratch; it computes a canonical path from the typed route registry, sets openGraph.type: "article" with publishedTime from the lesson record, and inherits metadataBase, twitter.card, the author, and the publisher from the root layout. The root layout sets metadataBase: new URL(site.canonicalUrl) once, with site.canonicalUrl itself built as https://${identity.domain} so a protocol typo is a type error, not a string bug.
This is also why the contract holds at the leaf. When the only things a page is allowed to supply are its canonical path, its article timestamps, and its social card fields (all drawn from the same registry that renders the page), there is no place for the page to disagree with itself. The title a crawler sees is the title in the registry. The description in og:description is the dek that also appears in the social card. The canonical is the route, computed once. A change to the registry changes all three at the same time.
the checklist
The practical audit for an App Router deployment reduces to a handful of yes-or-no checks, each backed by a type that refuses the wrong answer:
- Is
metadataBaseset in the root layout to a barehttps://origin parsed throughnew URL, not assembled from an environment string? - Is
titlea{ default, template }pair at the root, and does every leaf that overrides it do so deliberately rather than by accident? - Is
alternates.canonicalabsolute on every public route, including nested ones, and derived from the route registry rather than hardcoded? - Is
openGraph.imagesan absolute URL at the declared card size, and does the route have a correspondingopengraph-imagemodule? - Is
robotsone of two audited constants (indexable or private) rather than an ad-hoc string per page? - Do private routes omit canonical and social metadata entirely rather than publish them under
noindex? - Does a failed check throw during module evaluation, so the build fails instead of the markup?
The common thread is that none of these are runtime concerns. Every one is decidable when the route module is evaluated, which means every one can fail the build. The point of a typed metadata contract is not that it produces better metadata; it is that it makes the silently-degraded states impossible to express, so the checklist runs itself. On this site the contract is what makes a new public page safe to add: the registry records the title and dek, the metadata function turns them into the complete object, and the crawler-facing artifacts all read the same record, which is the subject of the next lesson.
sources
- web-discovery:
createPublicSiteMetadata,INDEXABLE_ROBOTS,NOINDEX_ROBOTS,OwnedPath, and the bare-HTTPS origin validation insrc/discovery.ts. - The monorepo behind this site (projects):
app/layout.tsxfor the inherited root metadata andapp/reference/[category]/[lesson]/page.tsxfor the derived per-route metadata. - Next.js Metadata API reference: the
metadataBase,titletemplate, andopenGraphfields described here.