SEO for Solos
Chapter 02Free

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

typescript
// 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:

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

bash
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 -d

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

text
Learn everything you need to know about technical SEO with our comprehensive guide.
text
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:

typescript
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

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

typescript
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

typescript
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

typescript
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.

Product analytics are optional. On the skill page, X advertising measurement is also optional and shares your visit and ad identifiers with X. Both are off until you allow them. Privacy policy.