Metadata
Titles, descriptions, canonicals, robots directives and social cards. The cheapest wins available and the most commonly half-finished layer on an otherwise well-built site.
7 min read · updated 2026-08-13· last reviewed 2026-07-26
Titles lead with the term, not the brand
The principle
The first 60 or so characters of a title carry nearly all its weight, both for matching and for the click. Spending the first 15 of them on a brand name nobody searched for is the most common self-inflicted wound in this layer.
Use a template with the brand as a suffix, and give the home page a keyword-led default rather than a brand-led one. The exception is a brand strong enough that the name is itself the query, which is a smaller set of companies than the ones that behave as though they are in it.
The implementation
// app/layout.tsx
export const metadata: Metadata = {
title: {
default: `${BRAND.name}: 112 SEO checks, 42 schema types, working code`,
template: `%s | ${BRAND.name}`,
},
};Every page then sets only its own title, and the suffix is applied once. A page that needs to override the template entirely uses title: { absolute: "..." }, which should be rare enough that each use is deliberate.
Every indexable URL has a unique title and description
The principle
Duplicate titles across a page tier are the clearest available signal that the tier was generated rather than written. It is the first thing a reviewer notices and the first thing a quality classifier can measure without understanding the content.
Uniqueness has to come from the entity, not from a counter. "Foo, page 2 of 40" is unique and useless.
The implementation
The title for a programmatic page should contain the entity name and at least one number computed from that entity:
export function generateMetadata({ params }: Props): Metadata {
const entity = getEntity(params.slug);
return pageMetadata({
title: `${entity.name}: ${entity.factCount} data points and how to use them`,
description: `${entity.name} has ${entity.factCount} tracked data points as of ${entity.updatedLabel}. This page covers what each one means and how it is measured.`,
path: `/tier/${entity.slug}`,
});
}Checking it is a one-liner once you have a sitemap:
curl -s https://example.com/sitemap.xml \
| grep -o '<loc>[^<]*' | cut -d'>' -f2 \
| while read u; do curl -s "$u" | grep -o '<title>[^<]*'; done \
| sort | uniq -dDescriptions contain a number and an offer
The principle
Descriptions do not rank. They earn the click, and they are what an answer engine most often quotes when summarising a result. A number is the cheapest specificity available and it survives truncation better than an adjective does.
The implementation
Compare these two, both under the truncation limit:
Learn everything you need to know about technical SEO with our comprehensive guide.112 implementation checks across 12 groups, each with a command you can run to verify it. Free.The second one tells a reader what they get and roughly how long it will take. The first one is true of several thousand pages.
Canonicals are absolute and self-referencing
The principle
A relative canonical resolves differently depending on the URL that was requested, which is exactly the ambiguity a canonical exists to remove. Every canonical should be absolute, and on a normal page it should point at itself.
A self-referencing canonical is not redundant. It is what tells a crawler that the URL with three tracking parameters appended is the same page as the clean one.
The implementation
One helper builds metadata for every page, so no route can forget:
export function pageMetadata(input: PageMetaInput) {
const url = canonicalUrl(input.path);
return {
title: input.title,
description: input.description,
alternates: { canonical: url },
openGraph: openGraph({ ...input, url, siteName: BRAND.name }),
twitter: twitterCard(input),
};
}Robots directives are explicit
The principle
The defaults limit snippet length and image preview size. max-snippet: -1 and max-image-preview: large are opt-in, and they are what let a result occupy more vertical space and carry a larger thumbnail. On a site where the content is the product, more snippet is almost always better.
The implementation
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
"max-video-preview": -1,
},
},Private routes need the opposite, and they need it as a meta tag rather than only as a robots.txt disallow:
export const metadata = pageMetadata({
title: "Your library",
description: "Purchased artefacts and downloads.",
path: "/library",
noindex: true,
});Social cards, including the parts platforms silently require
The principle
A link shared without an Open Graph image renders as a grey rectangle. The width, height, alt text and MIME type are not decoration: several platforms skip a card whose image dimensions they cannot determine without downloading it, and the alt text is what a screen reader announces when the card is embedded.
The implementation
export function openGraph(opts: OpenGraphOptions) {
const images = (opts.images ?? []).map((image) => ({
url: image.url,
width: image.width ?? 1200,
height: image.height ?? 630,
alt: image.alt,
type: image.type ?? inferImageType(image.url),
}));
return compact({ /* ... */ images: images.length > 0 ? images : undefined });
}X does not reliably fall back to Open Graph, so twitter:card needs to be set explicitly to summary_large_image. Without it the link renders in the small format, which is roughly a third of the visual footprint in a feed.
Icons, the manifest, and cache busting
The principle
Static assets carry a one-year immutable cache header, which is correct and fast. It also means a changed icon never reaches a returning visitor unless its URL changes. Append a version query to every icon URL and bump it when the icon changes.
The manifest is what decides how the site looks when installed, and it needs more than a name and two icons to be treated as complete: a maskable icon, a wide-form screenshot, categories, and a scope.
The implementation
icons: {
icon: [
{ url: "/favicon.ico?v=1", sizes: "48x48", type: "image/x-icon" },
{ url: "/icon.svg?v=1", type: "image/svg+xml" },
{ url: "/icon-192.png?v=1", sizes: "192x192", type: "image/png" },
],
apple: [{ url: "/apple-icon.png?v=1", sizes: "180x180", type: "image/png" }],
},
manifest: "/manifest.webmanifest",Checks this chapter covers
Each one has a command you can run against your own site.
- →A title template with a keyword-led default, not a brand-led onemeta-title-template
- →Every indexable URL has a unique title and descriptionmeta-unique-titles
- →Descriptions contain a number and a specific offermeta-description-with-number
- →Every page has a self-referencing absolute canonicalmeta-canonical-absolute
- →Canonical generation normalises slashes, case and query parametersmeta-canonical-normalised
- →Open Graph tags with a 1200x630 image, alt text and an explicit MIME typemeta-open-graph
- →twitter:card set to summary_large_image with its own title and descriptionmeta-twitter-card
- →Explicit robots directives, including max-snippet and max-image-preview for Googlebotmeta-robots-directives
- →hreflang alternates including x-default, even on a single-language sitemeta-hreflang-xdefault
- →A full icon set with cache-busted URLs and a complete web manifestmeta-icons-versioned
- →Authenticated routes carry a robots noindex tag as well as a robots.txt disallowmeta-noindex-private-routes