Metadata
Metadata covers 11 of the SEO for Soloschecklist's implementation checks. Titles, descriptions, canonicals and social cards. The cheapest wins on the list and the most commonly half-finished.
0 of 11
Saved in this browser
11 checks shown.
Metadata
Titles, descriptions, canonicals and social cards. The cheapest wins on the list and the most commonly half-finished.
A title template with a keyword-led default, not a brand-led one4/S
Why it matters
The first 60 characters of a title carry nearly all its weight. Leading with the brand on every page spends that budget on a word nobody searched for.
How to verify
curl -s https://example.com/ | grep -o '<title>[^<]*</title>'Then confirm the head term appears before the brand name.
Implementation
kit/seo/constants.ts/** * Code Kit: SEO primitives. * Implements checks: meta-canonical-absolute, meta-canonical-normalised, * meta-open-graph, meta-twitter-card, meta-title-template * * The only module in the kit permitted to read process.env, and it falls back * gracefully when the variable is absent. */ /** Resolve the site origin once. Callers should import this, never re-derive it. */ export function resolveSiteUrl(fallback: string): string { const fromEnv = process.env.NEXT_PUBLIC_SITE_URL ?? (process.env.VERCEL_PROJECT_PRODUCTION_URL ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` : undefined); return stripTrailingSlashes(fromEnv && fromEnv.length > 0 ? fromEnv : fallback); } function stripTrailingSlashes(value: string): string { return value.replace(/\/+$/, ""); } /** * Normalise a path into an absolute canonical URL. * * Handles the cases that actually break in production: * "" -> origin * "/" -> origin * "/a/" -> origin + "/a" (trailing slash stripped) * "//a" -> origin + "/a" (protocol-relative path collapsed) * "a/b" -> origin + "/a/b" (missing leading slash added) * "/a?b=1#c" -> query and fragment preserved, path normalised * "https://x/y" -> returned unchanged (already absolute) */ export function canonicalUrl(path: string | undefined, siteUrl: string): string { const origin = stripTrailingSlashes(siteUrl); if (!path) return origin; if (/^https?:\/\//i.test(path)) return stripTrailingSlashes(path) || path; // Split off query and fragment before touching slashes. const queryIndex = path.search(/[?#]/); const rawPath = queryIndex === -1 ? path : path.slice(0, queryIndex); const suffix = queryIndex === -1 ? "" : path.slice(queryIndex); let normalised = rawPath.replace(/\/{2,}/g, "/"); if (!normalised.startsWith("/")) normalised = `/${normalised}`; normalised = normalised.replace(/\/+$/, ""); if (normalised === "" || normalised === "/") { return suffix ? `${origin}/${suffix}` : origin; } return `${origin}${normalised}${suffix}`; } export type OpenGraphImage = { url: string; width?: number; height?: number; alt: string; type?: string; }; export type OpenGraphOptions = { title: string; description: string; url: string; siteName: string; locale?: string; type?: "website" | "article" | "profile" | "book"; images?: OpenGraphImage[]; publishedTime?: string; modifiedTime?: string; authors?: string[]; section?: string; tags?: string[]; }; const DEFAULT_OG_IMAGE_WIDTH = 1200; const DEFAULT_OG_IMAGE_HEIGHT = 630; /** Open Graph object with the defaults that matter, omitting anything undefined. */ export function openGraph(opts: OpenGraphOptions) { const images = (opts.images ?? []).map((image) => ({ url: image.url, width: image.width ?? DEFAULT_OG_IMAGE_WIDTH, height: image.height ?? DEFAULT_OG_IMAGE_HEIGHT, alt: image.alt, type: image.type ?? inferImageType(image.url), })); return compact({ title: opts.title, description: opts.description, url: opts.url, siteName: opts.siteName, locale: opts.locale ?? "en_US", type: opts.type ?? "website", images: images.length > 0 ? images : undefined, publishedTime: opts.publishedTime, modifiedTime: opts.modifiedTime, authors: opts.authors?.length ? opts.authors : undefined, section: opts.section, tags: opts.tags?.length ? opts.tags : undefined, }); } export type TwitterCardOptions = { title: string; description: string; images?: string[]; creator?: string; site?: string; }; export function twitterCard(opts: TwitterCardOptions) { return compact({ card: "summary_large_image" as const, title: opts.title, description: opts.description, images: opts.images?.length ? opts.images : undefined, creator: opts.creator, site: opts.site, }); } function inferImageType(url: string): string | undefined { const match = /\.(png|jpe?g|webp|avif|gif|svg)(?:\?|$)/i.exec(url); if (!match) return undefined; const ext = match[1]?.toLowerCase(); switch (ext) { case "png": return "image/png"; case "jpg": case "jpeg": return "image/jpeg"; case "webp": return "image/webp"; case "avif": return "image/avif"; case "gif": return "image/gif"; case "svg": return "image/svg+xml"; default: return undefined; } } /** * Drop undefined, null and empty-string values recursively. * * Structured data with `"author": ""` is worse than structured data without an * author: it asserts a fact that is empty. Every factory in the kit runs its * output through this. */ export function compact<T>(value: T): T { if (Array.isArray(value)) { const cleaned = value .map((item) => compact(item)) .filter((item) => item !== undefined && item !== null && item !== ""); return cleaned as unknown as T; } if (value && typeof value === "object" && !(value instanceof Date)) { const entries = Object.entries(value as Record<string, unknown>) .map(([key, val]) => [key, compact(val)] as const) .filter(([, val]) => { if (val === undefined || val === null || val === "") return false; if (Array.isArray(val) && val.length === 0) return false; if (typeof val === "object" && Object.keys(val as object).length === 0) return false; return true; }); return Object.fromEntries(entries) as T; } return value; }Every indexable URL has a unique title and description4/M
Why it matters
Duplicate titles across a page tier is the clearest signal that the tier is templated rather than written, and it is the first thing a manual reviewer notices.
How to verify
Extract titles from your sitemap and count duplicates: `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`.
Implementation snippet
The tested implementation from kit/pseo/template.tsx, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Descriptions contain a number and a specific offer3/S
Why it matters
Descriptions do not rank, they earn the click. A number is the cheapest specificity available and it survives truncation better than an adjective.
How to verify
curl -s https://example.com/ | grep -o '<meta name="description" content="[^"]*"'Then confirm it contains a digit.
Implementation snippet
The tested implementation from kit/seo/constants.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Every page has a self-referencing absolute canonical5/S
Why it matters
A relative canonical resolves differently depending on the requested URL, which is exactly the ambiguity a canonical exists to remove.
How to verify
curl -s https://example.com/pricing | grep -o '<link rel="canonical"[^>]*>'Then confirm the href is absolute and matches the requested URL.
Implementation
kit/seo/constants.ts/** * Code Kit: SEO primitives. * Implements checks: meta-canonical-absolute, meta-canonical-normalised, * meta-open-graph, meta-twitter-card, meta-title-template * * The only module in the kit permitted to read process.env, and it falls back * gracefully when the variable is absent. */ /** Resolve the site origin once. Callers should import this, never re-derive it. */ export function resolveSiteUrl(fallback: string): string { const fromEnv = process.env.NEXT_PUBLIC_SITE_URL ?? (process.env.VERCEL_PROJECT_PRODUCTION_URL ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` : undefined); return stripTrailingSlashes(fromEnv && fromEnv.length > 0 ? fromEnv : fallback); } function stripTrailingSlashes(value: string): string { return value.replace(/\/+$/, ""); } /** * Normalise a path into an absolute canonical URL. * * Handles the cases that actually break in production: * "" -> origin * "/" -> origin * "/a/" -> origin + "/a" (trailing slash stripped) * "//a" -> origin + "/a" (protocol-relative path collapsed) * "a/b" -> origin + "/a/b" (missing leading slash added) * "/a?b=1#c" -> query and fragment preserved, path normalised * "https://x/y" -> returned unchanged (already absolute) */ export function canonicalUrl(path: string | undefined, siteUrl: string): string { const origin = stripTrailingSlashes(siteUrl); if (!path) return origin; if (/^https?:\/\//i.test(path)) return stripTrailingSlashes(path) || path; // Split off query and fragment before touching slashes. const queryIndex = path.search(/[?#]/); const rawPath = queryIndex === -1 ? path : path.slice(0, queryIndex); const suffix = queryIndex === -1 ? "" : path.slice(queryIndex); let normalised = rawPath.replace(/\/{2,}/g, "/"); if (!normalised.startsWith("/")) normalised = `/${normalised}`; normalised = normalised.replace(/\/+$/, ""); if (normalised === "" || normalised === "/") { return suffix ? `${origin}/${suffix}` : origin; } return `${origin}${normalised}${suffix}`; } export type OpenGraphImage = { url: string; width?: number; height?: number; alt: string; type?: string; }; export type OpenGraphOptions = { title: string; description: string; url: string; siteName: string; locale?: string; type?: "website" | "article" | "profile" | "book"; images?: OpenGraphImage[]; publishedTime?: string; modifiedTime?: string; authors?: string[]; section?: string; tags?: string[]; }; const DEFAULT_OG_IMAGE_WIDTH = 1200; const DEFAULT_OG_IMAGE_HEIGHT = 630; /** Open Graph object with the defaults that matter, omitting anything undefined. */ export function openGraph(opts: OpenGraphOptions) { const images = (opts.images ?? []).map((image) => ({ url: image.url, width: image.width ?? DEFAULT_OG_IMAGE_WIDTH, height: image.height ?? DEFAULT_OG_IMAGE_HEIGHT, alt: image.alt, type: image.type ?? inferImageType(image.url), })); return compact({ title: opts.title, description: opts.description, url: opts.url, siteName: opts.siteName, locale: opts.locale ?? "en_US", type: opts.type ?? "website", images: images.length > 0 ? images : undefined, publishedTime: opts.publishedTime, modifiedTime: opts.modifiedTime, authors: opts.authors?.length ? opts.authors : undefined, section: opts.section, tags: opts.tags?.length ? opts.tags : undefined, }); } export type TwitterCardOptions = { title: string; description: string; images?: string[]; creator?: string; site?: string; }; export function twitterCard(opts: TwitterCardOptions) { return compact({ card: "summary_large_image" as const, title: opts.title, description: opts.description, images: opts.images?.length ? opts.images : undefined, creator: opts.creator, site: opts.site, }); } function inferImageType(url: string): string | undefined { const match = /\.(png|jpe?g|webp|avif|gif|svg)(?:\?|$)/i.exec(url); if (!match) return undefined; const ext = match[1]?.toLowerCase(); switch (ext) { case "png": return "image/png"; case "jpg": case "jpeg": return "image/jpeg"; case "webp": return "image/webp"; case "avif": return "image/avif"; case "gif": return "image/gif"; case "svg": return "image/svg+xml"; default: return undefined; } } /** * Drop undefined, null and empty-string values recursively. * * Structured data with `"author": ""` is worse than structured data without an * author: it asserts a fact that is empty. Every factory in the kit runs its * output through this. */ export function compact<T>(value: T): T { if (Array.isArray(value)) { const cleaned = value .map((item) => compact(item)) .filter((item) => item !== undefined && item !== null && item !== ""); return cleaned as unknown as T; } if (value && typeof value === "object" && !(value instanceof Date)) { const entries = Object.entries(value as Record<string, unknown>) .map(([key, val]) => [key, compact(val)] as const) .filter(([, val]) => { if (val === undefined || val === null || val === "") return false; if (Array.isArray(val) && val.length === 0) return false; if (typeof val === "object" && Object.keys(val as object).length === 0) return false; return true; }); return Object.fromEntries(entries) as T; } return value; }Canonical generation normalises slashes, case and query parameters4/S
Why it matters
The canonical function is called from every page. If it mishandles a double slash or a trailing slash, that bug is on every URL at once.
How to verify
Unit test it: assert `canonicalUrl('//a/')` and `canonicalUrl('a')` both produce the same absolute URL. The Code Kit ships the suite.
Implementation snippet
The tested implementation from kit/seo/constants.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Open Graph tags with a 1200x630 image, alt text and an explicit MIME type3/S
Why it matters
A link shared without an OG image renders as a bare grey rectangle. The alt text and MIME type are what stop some platforms from silently dropping the card.
How to verify
curl -s https://example.com/ | grep -o '<meta property="og:[^>]*>'Then confirm og:title, og:description, og:image, og:image:width, og:image:height, og:image:alt and og:type are present.
Implementation snippet
The tested implementation from kit/seo/constants.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.twitter:card set to summary_large_image with its own title and description2/S
Why it matters
X does not always fall back to Open Graph. Without the explicit card type the link renders in the small format, which halves its visual footprint in a feed.
How to verify
curl -s https://example.com/ | grep 'twitter:card'Then confirm the value is summary_large_image.
Implementation snippet
The tested implementation from kit/seo/constants.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Explicit robots directives, including max-snippet and max-image-preview for Googlebot3/S
Why it matters
Defaults limit snippet length and image preview size. `max-snippet: -1` and `max-image-preview: large` are what let a page occupy more of the result, and they are opt-in.
How to verify
curl -s https://example.com/ | grep -o '<meta name="googlebot"[^>]*>'Then confirm max-image-preview:large and max-snippet:-1.
Implementation snippet
The tested implementation from kit/seo/constants.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.hreflang alternates including x-default, even on a single-language site2/S
Why it matters
A single-language site still benefits from declaring x-default: it tells the crawler there is no other variant to look for, which prevents speculative fetches.
How to verify
curl -s https://example.com/ | grep hreflangThen confirm both the language tag and x-default appear.
Implementation snippet
The tested implementation from kit/seo/sitemap.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.A full icon set with cache-busted URLs and a complete web manifest2/M
Why it matters
Static assets carry a one-year immutable cache header. Without a version query, a changed icon never reaches a returning visitor, and the manifest is what decides how the site looks when installed.
How to verify
curl -s https://example.com/manifest.webmanifest | jq '.icons, .screenshots'Then confirm a 192px icon, a 512px icon, a maskable icon and at least one wide-form screenshot.
Implementation snippet
The tested implementation from kit/seo/constants.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Authenticated routes carry a robots noindex tag as well as a robots.txt disallow3/S
Why it matters
robots.txt stops the crawl, not the indexing. A disallowed URL that someone links to can still appear in results as a bare URL with no snippet. The meta tag is what removes it, and the crawler has to be allowed to fetch the page to see it.
How to verify
curl -s https://example.com/library | grep -o '<meta name="robots"[^>]*>'Then confirm noindex is present.
Implementation snippet
The tested implementation from kit/seo/constants.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.