The 112-check SEO implementation checklist
SEO for Solos publishes 112 implementation checks across 12 groups, each with a command you can run to verify it. It is free, it needs no account, and it is genuinely useful empty.
0 of 112
Saved in this browser
112 checks shown.
Foundation
The base layer every other check assumes. Get these wrong and nothing above them can work.
One constant holds the site origin, and nothing else hardcodes it3/S
Why it matters
A domain literal scattered across 40 files is why staging URLs leak into production canonicals and why a domain migration takes a week. One constant makes it a one-line change.
How to verify
grep -rn 'https://yourdomain' --include='*.ts' --include='*.tsx' . | grep -v node_modules | grep -v lib/brand.tsThen confirm the result is empty.
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; }One canonical host, with a permanent redirect from the other4/S
Why it matters
Serving the same content on the apex and the www subdomain splits link equity and doubles the crawl budget spent on identical pages.
How to verify
curl -sI https://example.com | grep -i '^location'Then confirm a 301 to the canonical host. Then run the reverse and confirm it returns 200, not a redirect loop.
Implementation snippet
The tested implementation from kit/config/next.config.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.HTTPS everywhere, with HSTS and no mixed content3/S
Why it matters
A single http asset on an https page blocks the padlock and, in modern browsers, is blocked outright. HSTS removes the redirect hop on every repeat visit.
How to verify
curl -sI https://example.com | grep -i strict-transport-securityThen confirm a max-age of at least 31536000 with includeSubDomains.
Implementation snippet
The tested implementation from kit/config/headers.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.One trailing-slash policy, enforced by redirect3/S
Why it matters
`/about` and `/about/` are different URLs to a crawler. Serving both with 200 creates a duplicate for every page on the site.
How to verify
curl -sI https://example.com/about/ | head -1Then confirm a 301 to the canonical form (or the reverse, consistently).
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.A missing page returns a real 404 status, not a 200 with an error message4/S
Why it matters
A soft 404 gets indexed. Google then treats an unknown share of your site as thin content, and the pages compete with the real ones.
How to verify
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/this-does-not-existThen confirm it prints 404.
Implementation
kit/pseo/gating.ts/** * Code Kit: entity gating. * Implements checks: pseo-entity-gate, found-404-returns-404 * * An ungated dynamic route renders a page for any slug it is given, including * slugs a crawler invented by mangling a URL. Each one is an indexable page * with no content. This module is the gate. */ export type GatedEntity = { slug: string }; export type EntityGate<T extends GatedEntity> = { /** Every valid slug, for static generation. */ slugs: () => string[]; /** Returns undefined for anything not on the list. Never a partial match. */ resolve: (slug: string) => T | undefined; /** Canonical slug to redirect to, or undefined. Aliases never render. */ resolveAlias: (slug: string) => string | undefined; all: () => readonly T[]; size: number; }; export function createEntityGate<T extends GatedEntity>( entities: readonly T[], aliases: Readonly<Record<string, string>> = {}, ): EntityGate<T> { const bySlug = new Map(entities.map((entity) => [entity.slug, entity])); // A duplicate slug means two entities compete for one URL. Fail loudly at // module load rather than serving whichever one won the Map insertion. if (bySlug.size !== entities.length) { const seen = new Set<string>(); const duplicates = entities .map((entity) => entity.slug) .filter((slug) => (seen.has(slug) ? true : (seen.add(slug), false))); throw new Error(`Duplicate entity slugs: ${[...new Set(duplicates)].join(", ")}`); } return { slugs: () => entities.map((entity) => entity.slug), resolve: (slug) => bySlug.get(slug), resolveAlias: (slug) => { const target = aliases[slug]; return target && bySlug.has(target) ? target : undefined; }, all: () => entities, size: entities.length, }; } /** * Slug normalisation for the URLs you generate, not for the ones you receive. * Receiving a slug that needs normalising means the link that produced it is * wrong; fix the link rather than accepting both forms. */ export function toSlug(value: string): string { return value .toLowerCase() .normalize("NFKD") .replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); }
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.
Structured data
JSON-LD that describes the page to a machine, composed into one graph with stable identifiers rather than scattered across a dozen script tags.
One JSON-LD script per page, holding one @graph4/M
Why it matters
Multiple disconnected script tags describe several unrelated things. One graph describes one page, and lets nodes reference each other instead of repeating themselves.
How to verify
curl -s https://example.com/ | grep -c 'application/ld+json'Then confirm it prints 1.
Implementation
kit/seo/schema/graph.ts/** * Code Kit: the sitewide @graph composer. * Implements checks: schema-graph-anchors, schema-single-script, schema-no-duplicates * * One <script type="application/ld+json"> per page holding one @graph array. * Nodes cross-reference each other by @id instead of duplicating themselves, * which is what stops a site from shipping the same Organization object 2,000 * times and lets a crawler resolve one entity across every page. */ import { compact } from "../constants"; import { organization, website, type OrganizationOptions, type WebSiteOptions } from "./organization"; import { product, type ProductOptions } from "./commerce"; import { type JsonLdObject, SCHEMA_CONTEXT, nodeId, ref } from "./types"; export type SiteGraphOptions = { siteUrl: string; organization: Omit<OrganizationOptions, "siteUrl">; website: Omit<WebSiteOptions, "siteUrl" | "publisher">; /** Omit on sites that do not sell a product. */ product?: Omit<ProductOptions, "siteUrl">; /** Page-level nodes: WebPage, BreadcrumbList, Article, FAQPage, and so on. */ additionalNodes?: Array<JsonLdObject | undefined>; }; /** * Compose the sitewide graph. * * The three anchors are stable and cross-referenced: * #organization is the publisher of #website and the brand of #product * #website is the isPartOf target for every page node * #product is the thing being sold */ export function siteGraph(opts: SiteGraphOptions): JsonLdObject { const orgId = nodeId(opts.siteUrl, "#organization"); const nodes: Array<JsonLdObject | undefined> = [ organization({ siteUrl: opts.siteUrl, ...opts.organization }), website({ siteUrl: opts.siteUrl, publisher: ref(orgId), ...opts.website }), opts.product ? product({ siteUrl: opts.siteUrl, ...opts.product, brandName: opts.product.brandName, }) : undefined, ...(opts.additionalNodes ?? []), ]; return { "@context": SCHEMA_CONTEXT, "@graph": dedupeById(nodes.filter(isPresent)) as unknown as JsonLdObject[], } as unknown as JsonLdObject; } /** * Build a page-level graph that references the sitewide anchors without * repeating them. Use on every page that is not the home page. */ export function pageGraph(opts: { siteUrl: string; nodes: Array<JsonLdObject | undefined>; }): JsonLdObject { return { "@context": SCHEMA_CONTEXT, "@graph": dedupeById(opts.nodes.filter(isPresent)) as unknown as JsonLdObject[], } as unknown as JsonLdObject; } function isPresent<T>(value: T | undefined | null): value is T { return value !== undefined && value !== null; } /** Two nodes with the same @id in one graph is a validation error. Last write wins. */ function dedupeById(nodes: JsonLdObject[]): JsonLdObject[] { const seen = new Map<string, JsonLdObject>(); const anonymous: JsonLdObject[] = []; for (const node of nodes) { const id = typeof node["@id"] === "string" ? node["@id"] : undefined; if (id) seen.set(id, node); else anonymous.push(node); } return [...seen.values(), ...anonymous]; } /** * Collect every @id declared in a graph and every @id referenced by it, so a * test can assert that no reference dangles. * * The distinction that matters: an object carrying `@id` plus other properties * DECLARES a node, at any nesting depth. An object carrying nothing but `@id` * REFERENCES one. A nested ImageObject with its own `@id` is a declaration, and * treating it as a dangling reference would be a false positive. */ export function graphIdIntegrity(graph: JsonLdObject): { declared: Set<string>; referenced: Set<string>; dangling: string[]; } { const declared = new Set<string>(); const referenced = new Set<string>(); const nodes = (graph["@graph"] as unknown as JsonLdObject[]) ?? []; const walk = (value: unknown): void => { if (Array.isArray(value)) { for (const item of value) walk(item); return; } if (!value || typeof value !== "object") return; const obj = value as Record<string, unknown>; const keys = Object.keys(obj); const id = typeof obj["@id"] === "string" ? obj["@id"] : undefined; if (id) { if (keys.length === 1) referenced.add(id); else declared.add(id); } for (const [key, val] of Object.entries(obj)) { if (key === "@id") continue; walk(val); } }; for (const node of nodes) walk(node); return { declared, referenced, dangling: [...referenced].filter((id) => !declared.has(id)), }; } export { compact };Stable @id anchors that cross-reference rather than duplicate4/M
Why it matters
Without stable identifiers, every page ships its own copy of the Organization node and a crawler has no way to know they are the same entity. With them, one entity is asserted 2,000 times from 2,000 pages.
How to verify
Extract the graph and confirm every `{"@id": ...}` reference resolves to a node declared in the same document: `curl -s https://example.com/ | grep -o '<script type="application/ld+json">[^<]*' | sed 's/.*json">//' | jq '[..|."@id"?|strings]|unique'`.
Implementation snippet
The tested implementation from kit/seo/schema/graph.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Optional fields are omitted, never emitted as empty strings3/S
Why it matters
`"author": ""` asserts that the author is nothing. Omission asserts nothing at all, which is the honest and the machine-friendly state.
How to verify
Pipe the graph through `jq '[..|select(type=="string" and .=="")]|length'` and confirm it prints 0.
Implementation snippet
The tested implementation from kit/seo/schema/types.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Organization node with logo, and sameAs listing only profiles that exist3/S
Why it matters
The Organization node is the anchor every other node points at. An invented social profile in sameAs is a broken entity claim that is trivially checked.
How to verify
Paste the URL into Google's Rich Results Test and confirm the Organization is detected with no errors. Then open every sameAs URL and confirm each one resolves.
Implementation snippet
The tested implementation from kit/seo/schema/organization.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.WebSite node with publisher and, where a search route exists, a SearchAction2/S
Why it matters
The WebSite node is the isPartOf target for every page node, which is what ties a 2,000-page site together as one property rather than 2,000 documents.
How to verify
Confirm the WebSite node's publisher is an @id reference to the Organization node, not an inline copy of it.
Implementation snippet
The tested implementation from kit/seo/schema/organization.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.BreadcrumbList on every page below the root, with the last item carrying no URL3/S
Why it matters
Breadcrumbs replace the URL in the search result, which is more readable and more clickable. The final item is the current page and must not carry an item URL, per Google's documented shape.
How to verify
Run the page through the Rich Results Test and confirm Breadcrumbs are detected with no warning about the last element.
Implementation
kit/seo/schema/navigation.ts/** * Code Kit: navigation and list schema factories. * Implements checks: schema-breadcrumb, schema-itemlist, nav-breadcrumb-visible */ import { compact } from "../constants"; import { type JsonLdObject } from "./types"; export type BreadcrumbItem = { name: string; url: string }; /** * BreadcrumbList. * * The last item is the current page and carries no `item` URL, per Google's * documented shape. A breadcrumb in schema without a matching visible * <nav aria-label="Breadcrumb"> is a mismatch between markup and page, so the * kit's page template renders both from the same array. */ export function breadcrumbList(opts: { url: string; items: BreadcrumbItem[]; }): JsonLdObject | undefined { if (opts.items.length === 0) return undefined; const lastIndex = opts.items.length - 1; return { "@type": "BreadcrumbList", "@id": `${opts.url}#breadcrumb`, itemListElement: opts.items.map((item, index) => compact({ "@type": "ListItem", position: index + 1, name: item.name, item: index === lastIndex ? undefined : item.url, }), ), }; } export type ItemListEntry = { name: string; url: string; description?: string; position?: number; }; export function itemList(opts: { url: string; name?: string; items: ItemListEntry[]; /** Use "ItemList" for a ranked list, omit ordering for an unranked set. */ ordered?: boolean; }): JsonLdObject | undefined { if (opts.items.length === 0) return undefined; return compact({ "@type": "ItemList", "@id": `${opts.url}#itemlist`, name: opts.name, numberOfItems: opts.items.length, itemListOrder: opts.ordered ? "https://schema.org/ItemListOrderAscending" : "https://schema.org/ItemListUnordered", itemListElement: opts.items.map((item, index) => compact({ "@type": "ListItem", position: item.position ?? index + 1, name: item.name, url: item.url, description: item.description, }), ), }) as JsonLdObject; } export type SiteNavigationOptions = { url: string; items: Array<{ name: string; url: string }>; }; export function siteNavigationElement(opts: SiteNavigationOptions): JsonLdObject | undefined { if (opts.items.length === 0) return undefined; return { "@type": "SiteNavigationElement", "@id": `${opts.url}#navigation`, name: opts.items.map((item) => item.name), url: opts.items.map((item) => item.url), }; }Article schema with dateModified, wordCount, articleSection and a resolvable author3/M
Why it matters
dateModified is what makes a freshness signal legible. An author that is a bare string cannot be connected to anything; an author that is a Person node with a URL can.
How to verify
Rich Results Test on a blog post. Confirm datePublished, dateModified and author are all present and that author resolves to a node with a url.
Implementation snippet
The tested implementation from kit/seo/schema/content.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.FAQPage only where the questions are visible on the page3/S
Why it matters
Schema-only FAQs are a documented manual action risk. The remaining value is answer extraction by AI engines, which read the visible text anyway.
How to verify
For each question in the FAQPage node, confirm the exact string appears in the rendered HTML: `curl -s URL | grep -F "$QUESTION"`.
Implementation
kit/seo/schema/content.ts/** * Code Kit: content schema factories. * Implements checks: schema-article, schema-faqpage, schema-howto, * schema-speakable, content-faq-visible */ import { compact } from "../constants"; import { type JsonLdObject, type NodeRef, isoDate, ref } from "./types"; export type ArticleOptions = { type?: "Article" | "BlogPosting" | "TechArticle"; url: string; headline: string; description: string; datePublished: string | Date; dateModified?: string | Date; author: JsonLdObject | NodeRef; publisher: NodeRef; imageUrl?: string; imageWidth?: number; imageHeight?: number; wordCount?: number; articleSection?: string; keywords?: string[]; inLanguage?: string; isPartOf?: NodeRef; about?: { name: string; description?: string }; }; export function article(opts: ArticleOptions): JsonLdObject { // Google truncates headlines past 110 characters in rich results. const headline = opts.headline.length > 110 ? `${opts.headline.slice(0, 107)}...` : opts.headline; return compact({ "@type": opts.type ?? "Article", "@id": `${opts.url}#article`, url: opts.url, mainEntityOfPage: { "@type": "WebPage", "@id": opts.url }, headline, description: opts.description, datePublished: isoDate(opts.datePublished), dateModified: isoDate(opts.dateModified ?? opts.datePublished), author: opts.author, publisher: opts.publisher, image: opts.imageUrl ? compact({ "@type": "ImageObject", url: opts.imageUrl, width: opts.imageWidth, height: opts.imageHeight, }) : undefined, wordCount: opts.wordCount, articleSection: opts.articleSection, keywords: opts.keywords?.length ? opts.keywords.join(", ") : undefined, inLanguage: opts.inLanguage ?? "en-US", isPartOf: opts.isPartOf, about: opts.about ? compact({ "@type": "Thing", name: opts.about.name, description: opts.about.description, }) : undefined, }) as JsonLdObject; } export type FaqEntry = { question: string; answer: string }; /** * FAQPage. * * Every question and answer passed here MUST also be visible in the rendered * HTML. Schema-only FAQs are a manual action risk, and since 2023 Google shows * FAQ rich results for a narrow set of sites anyway. The value now is answer * extraction by AI engines, which read the visible text as well. */ export function faqPage(opts: { url: string; faqs: FaqEntry[] }): JsonLdObject | undefined { if (opts.faqs.length === 0) return undefined; return compact({ "@type": "FAQPage", "@id": `${opts.url}#faq`, mainEntity: opts.faqs.map((faq) => ({ "@type": "Question", name: faq.question, acceptedAnswer: { "@type": "Answer", text: faq.answer }, })), }) as JsonLdObject; } export type HowToStep = { name: string; text: string; url?: string; imageUrl?: string; }; export function howTo(opts: { url: string; name: string; description: string; steps: HowToStep[]; totalTime?: string; tools?: string[]; supplies?: string[]; }): JsonLdObject | undefined { if (opts.steps.length === 0) return undefined; return compact({ "@type": "HowTo", "@id": `${opts.url}#howto`, name: opts.name, description: opts.description, totalTime: opts.totalTime, tool: opts.tools?.map((name) => ({ "@type": "HowToTool", name })), supply: opts.supplies?.map((name) => ({ "@type": "HowToSupply", name })), step: opts.steps.map((step, index) => compact({ "@type": "HowToStep", position: index + 1, name: step.name, text: step.text, url: step.url, image: step.imageUrl, }), ), }) as JsonLdObject; } /** * SpeakableSpecification. * * Names the CSS selectors that hold the canonical answer on a page. Voice * surfaces and some answer engines read it. Point it at short, self-contained * sentences that restate their subject, never at a heading alone. */ export function speakable(cssSelectors: string[]): JsonLdObject | undefined { if (cssSelectors.length === 0) return undefined; return { "@type": "SpeakableSpecification", cssSelector: cssSelectors, }; } export type WebPageOptions = { url: string; name: string; description: string; isPartOf: NodeRef; breadcrumb?: NodeRef; primaryImageUrl?: string; datePublished?: string | Date; dateModified?: string | Date; inLanguage?: string; speakableSelectors?: string[]; about?: NodeRef; }; export function webPage(opts: WebPageOptions): JsonLdObject { return compact({ "@type": "WebPage", "@id": `${opts.url}#webpage`, url: opts.url, name: opts.name, description: opts.description, isPartOf: opts.isPartOf, breadcrumb: opts.breadcrumb, primaryImageOfPage: opts.primaryImageUrl ? { "@type": "ImageObject", url: opts.primaryImageUrl } : undefined, datePublished: isoDate(opts.datePublished), dateModified: isoDate(opts.dateModified), inLanguage: opts.inLanguage ?? "en-US", about: opts.about, speakable: opts.speakableSelectors?.length ? speakable(opts.speakableSelectors) : undefined, }) as JsonLdObject; } export type CollectionPageOptions = { url: string; name: string; description: string; isPartOf: NodeRef; breadcrumb?: NodeRef; itemList?: JsonLdObject; dateModified?: string | Date; }; export function collectionPage(opts: CollectionPageOptions): JsonLdObject { return compact({ "@type": "CollectionPage", "@id": `${opts.url}#collection`, url: opts.url, name: opts.name, description: opts.description, isPartOf: opts.isPartOf, breadcrumb: opts.breadcrumb, dateModified: isoDate(opts.dateModified), mainEntity: opts.itemList, }) as JsonLdObject; } export { ref };FAQ answers are in the HTML whether the accordion is open or closed3/S
Why it matters
An accordion that mounts its content on click ships an empty page to a crawler. A native details element ships the content either way.
How to verify
curl -s URL | grep -c '<details'Then confirm the answer text appears in the same curl output, with JavaScript never executed.
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.HowTo schema on procedural pages, with numbered steps matching the visible ones2/M
Why it matters
Procedural content is what answer engines quote most often. Numbered steps in schema make the boundaries of each step unambiguous to a parser.
How to verify
Rich Results Test on a tool page and confirm HowTo is detected with the same number of steps as the page renders.
Implementation snippet
The tested implementation from kit/seo/schema/content.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Product and Offer schema generated from the pricing source of truth3/M
Why it matters
A price in schema that disagrees with the price on the page is a mismatch a shopping crawler will act on. Reading both from one module makes divergence impossible.
How to verify
Change a price in the pricing module, rebuild, and confirm the rendered price and the schema price both move: `curl -s /pricing | grep -o '"price":"[0-9.]*"'`.
Implementation snippet
The tested implementation from kit/seo/schema/commerce.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Offer nodes carry priceCurrency, availability and a UnitPriceSpecification2/S
Why it matters
A price without a currency is not a price. UnitPriceSpecification is what distinguishes a one-time charge from a recurring one, which is the field buyers most often ask about.
How to verify
Pipe the graph through `jq '..|select(."@type"=="Offer")'` and confirm price, priceCurrency and availability are all present.
Implementation snippet
The tested implementation from kit/seo/schema/commerce.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.SoftwareApplication schema on every tool page, with a free Offer where the tool is free2/S
Why it matters
A free Offer with price 0 is what makes a free tool eligible for the treatment free tools get. Omitting it leaves the page describing an application with unknown cost.
How to verify
Rich Results Test on a tool page and confirm SoftwareApplication is detected with an offers node.
Implementation snippet
The tested implementation from kit/seo/schema/commerce.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Service schema on service pages, distinct from Product1/S
Why it matters
A consulting engagement is not a product. Using Product for it produces a shopping-style entity claim that will never be satisfied.
How to verify
Confirm the service page's graph contains a Service node and no Product node.
Implementation snippet
The tested implementation from kit/seo/schema/commerce.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.DefinedTerm per glossary entry, inside one DefinedTermSet2/M
Why it matters
A glossary that emits 80 unconnected definitions is 80 orphans. A DefinedTermSet makes it one reference work with 80 parts.
How to verify
curl -s /glossary | jq '[."@graph"[]|select(."@type"=="DefinedTermSet")|.hasDefinedTerm|length]'Then confirm the count matches the term list length.
Implementation snippet
The tested implementation from kit/seo/schema/terms.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.SpeakableSpecification naming the selectors that hold the canonical answer2/S
Why it matters
It costs one array of CSS selectors and it forces a useful discipline: you have to have a short, self-contained answer somewhere on the page to point it at.
How to verify
Confirm every selector in the speakable node matches an element in the rendered HTML, and that the matched text is a complete sentence.
Implementation snippet
The tested implementation from kit/seo/schema/content.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.ProfilePage and Person schema on author pages, with no invented credentials2/S
Why it matters
Author entities are a real E-E-A-T signal. A fabricated one is a liability, and omitted fields cost nothing.
How to verify
Confirm the author page emits ProfilePage with a mainEntity Person, and that every field present is verifiable.
Implementation snippet
The tested implementation from kit/seo/schema/organization.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.ItemList on hub and index pages2/S
Why it matters
A hub page is a list. Describing it as one tells a crawler the page's purpose is navigation, which is what it is.
How to verify
Confirm the hub page's graph contains an ItemList whose numberOfItems matches the rendered count.
Implementation snippet
The tested implementation from kit/seo/schema/navigation.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.No @id is declared twice in one graph3/S
Why it matters
Two nodes with the same identifier is a validation error and an ambiguous entity claim. It happens the moment two composers both add the Organization node.
How to verify
Pipe the graph through `jq '[."@graph"[]."@id"]|group_by(.)|map(select(length>1))'` and confirm the result is empty.
Implementation snippet
The tested implementation from kit/seo/schema/graph.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.
Crawling and indexing
robots.txt, sitemaps, feeds and push submission. Controls what gets fetched and how often.
robots.txt is generated from code, not maintained by hand3/S
Why it matters
A hand-maintained robots.txt drifts from the routes it is supposed to describe. Generating it means adding a private route section cannot forget to disallow it.
How to verify
Confirm no static robots.txt exists in the public directory and that `curl -s https://example.com/robots.txt` returns generated output.
Implementation
kit/seo/robots.ts/** * Code Kit: bot-economics robots policy. * Implements checks: crawl-robots-generated, crawl-bot-economics, * crawl-private-routes-disallowed, crawl-sitemap-declared * * The decision nobody makes deliberately: which crawlers get your content. * The useful split is not "AI bots" versus "search bots". It is whether a * crawler can send you traffic or a citation in return for the bandwidth. * * Search crawlers index you and send clicks. Allow. * Citation crawlers fetch a page to answer a live query * and name the source. Allow. * Training crawlers build a model. No link, no click, * no attribution. Your call. * * Blocking a training crawler does not remove you from that company's answer * engine if its citation crawler is a separate user agent, which for OpenAI, * Anthropic, Perplexity and Google it currently is. That asymmetry is the whole * mechanism. */ export type CrawlerClass = "search" | "citation" | "training" | "seo-tool" | "archive"; export type CrawlerProfile = { userAgent: string; operator: string; class: CrawlerClass; /** Does allowing this crawler produce visits or citations? */ sendsReferralTraffic: boolean; purpose: string; documentationUrl?: string; }; /** * Crawler registry. * * Verified against each operator's published documentation. Recheck quarterly: * user agent names change, and new ones appear faster than anyone updates * robots.txt. */ export const CRAWLERS: CrawlerProfile[] = [ { userAgent: "Googlebot", operator: "Google", class: "search", sendsReferralTraffic: true, purpose: "Google Search index. Also feeds AI Overviews.", documentationUrl: "https://developers.google.com/search/docs/crawling-indexing/overview-google-crawlers", }, { userAgent: "Bingbot", operator: "Microsoft", class: "search", sendsReferralTraffic: true, purpose: "Bing index. Also the retrieval layer behind Copilot.", documentationUrl: "https://www.bing.com/webmasters/help/which-crawlers-does-bing-use-8c184ec0", }, { userAgent: "DuckDuckBot", operator: "DuckDuckGo", class: "search", sendsReferralTraffic: true, purpose: "DuckDuckGo index.", }, { userAgent: "Applebot", operator: "Apple", class: "search", sendsReferralTraffic: true, purpose: "Siri and Spotlight suggestions.", documentationUrl: "https://support.apple.com/en-us/119829", }, { userAgent: "OAI-SearchBot", operator: "OpenAI", class: "citation", sendsReferralTraffic: true, purpose: "ChatGPT search index. Links back to the source.", documentationUrl: "https://platform.openai.com/docs/bots", }, { userAgent: "ChatGPT-User", operator: "OpenAI", class: "citation", sendsReferralTraffic: true, purpose: "Live fetch when a user asks ChatGPT about a specific URL.", documentationUrl: "https://platform.openai.com/docs/bots", }, { userAgent: "PerplexityBot", operator: "Perplexity", class: "citation", sendsReferralTraffic: true, purpose: "Perplexity answer index. Cites sources inline.", documentationUrl: "https://docs.perplexity.ai/guides/bots", }, { userAgent: "ClaudeBot", operator: "Anthropic", class: "citation", sendsReferralTraffic: true, purpose: "Claude retrieval and citation.", documentationUrl: "https://support.anthropic.com/en/articles/8896518", }, { userAgent: "Claude-User", operator: "Anthropic", class: "citation", sendsReferralTraffic: true, purpose: "Live fetch when a Claude user asks about a specific URL.", }, { userAgent: "Applebot-Extended", operator: "Apple", class: "training", sendsReferralTraffic: false, purpose: "Apple foundation model training. Opting out does not affect Siri.", documentationUrl: "https://support.apple.com/en-us/119829", }, { userAgent: "GPTBot", operator: "OpenAI", class: "training", sendsReferralTraffic: false, purpose: "Model training corpus. Separate from OAI-SearchBot.", documentationUrl: "https://platform.openai.com/docs/bots", }, { userAgent: "Google-Extended", operator: "Google", class: "training", sendsReferralTraffic: false, purpose: "Gemini training. Blocking it does not remove you from Search.", documentationUrl: "https://developers.google.com/search/docs/crawling-indexing/overview-google-crawlers", }, { userAgent: "anthropic-ai", operator: "Anthropic", class: "training", sendsReferralTraffic: false, purpose: "Legacy training crawler identifier.", }, { userAgent: "CCBot", operator: "Common Crawl", class: "training", sendsReferralTraffic: false, purpose: "Open crawl corpus used as training data by many labs.", documentationUrl: "https://commoncrawl.org/ccbot", }, { userAgent: "Bytespider", operator: "ByteDance", class: "training", sendsReferralTraffic: false, purpose: "Training corpus. Widely reported to ignore robots.txt.", }, { userAgent: "AhrefsBot", operator: "Ahrefs", class: "seo-tool", sendsReferralTraffic: false, purpose: "Backlink index. Allowing it makes your own backlink data visible to you and to competitors.", }, { userAgent: "SemrushBot", operator: "Semrush", class: "seo-tool", sendsReferralTraffic: false, purpose: "Competitive index. Same trade as AhrefsBot.", }, { userAgent: "ia_archiver", operator: "Internet Archive", class: "archive", sendsReferralTraffic: false, purpose: "Wayback Machine preservation.", }, ]; export type RobotsPolicy = { /** Crawler classes permitted to crawl. */ allowClasses: CrawlerClass[]; /** Paths every permitted crawler is denied. */ disallowPaths: string[]; /** Absolute sitemap URLs. */ sitemaps: string[]; /** Canonical host, no scheme. Bing reads this; Google ignores it. */ host?: string; /** Extra user agents to deny outright, beyond the class rules. */ denyUserAgents?: string[]; /** Emit the reasoning as comments. Leave on: people read this file. */ annotate?: boolean; crawlers?: CrawlerProfile[]; }; export const RECOMMENDED_POLICY: Pick<RobotsPolicy, "allowClasses"> = { // Allow anything that can send a visit or a citation. Deny training-only // crawlers and commercial SEO indexes, which take bandwidth and return nothing. allowClasses: ["search", "citation"], }; export type RobotsRule = { userAgent: string | string[]; allow?: string | string[]; disallow?: string | string[]; }; /** Structured output, for `app/robots.ts` in Next.js. */ export function robotsRules(policy: RobotsPolicy): RobotsRule[] { const crawlers = policy.crawlers ?? CRAWLERS; const allowed = crawlers.filter((c) => policy.allowClasses.includes(c.class)); const denied = crawlers.filter((c) => !policy.allowClasses.includes(c.class)); const denyList = [ ...denied.map((c) => c.userAgent), ...(policy.denyUserAgents ?? []), ]; const rules: RobotsRule[] = [ // The wildcard rule governs every crawler not named below, including ones // that do not exist yet. { userAgent: "*", allow: "/", disallow: policy.disallowPaths }, ]; for (const crawler of allowed) { rules.push({ userAgent: crawler.userAgent, allow: "/", disallow: policy.disallowPaths }); } for (const userAgent of denyList) { rules.push({ userAgent, disallow: "/" }); } return rules; } /** Plain text output, for a static robots.txt or the generator tool. */ export function robotsTxt(policy: RobotsPolicy): string { const crawlers = policy.crawlers ?? CRAWLERS; const annotate = policy.annotate ?? true; const lines: string[] = []; if (annotate) { lines.push( "# robots.txt", "#", "# Policy: allow any crawler that can return a visit or a citation.", "# Deny crawlers that consume bandwidth and return neither.", "#", ); } const emit = (rule: RobotsRule, comment?: string) => { if (annotate && comment) lines.push(`# ${comment}`); const agents = Array.isArray(rule.userAgent) ? rule.userAgent : [rule.userAgent]; for (const agent of agents) lines.push(`User-agent: ${agent}`); for (const allow of toArray(rule.allow)) lines.push(`Allow: ${allow}`); for (const disallow of toArray(rule.disallow)) lines.push(`Disallow: ${disallow}`); lines.push(""); }; emit( { userAgent: "*", allow: "/", disallow: policy.disallowPaths }, "Default policy for every crawler not named below.", ); for (const crawler of crawlers.filter((c) => policy.allowClasses.includes(c.class))) { emit( { userAgent: crawler.userAgent, allow: "/", disallow: policy.disallowPaths }, `${crawler.operator}: ${crawler.purpose}`, ); } const denied = crawlers.filter((c) => !policy.allowClasses.includes(c.class)); for (const crawler of denied) { emit( { userAgent: crawler.userAgent, disallow: "/" }, `${crawler.operator}: ${crawler.purpose} No referral traffic.`, ); } for (const userAgent of policy.denyUserAgents ?? []) { emit({ userAgent, disallow: "/" }); } for (const sitemap of policy.sitemaps) lines.push(`Sitemap: ${sitemap}`); if (policy.host) lines.push(`Host: ${policy.host}`); return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`; } function toArray(value: string | string[] | undefined): string[] { if (!value) return []; return Array.isArray(value) ? value : [value]; }The crawler policy is a deliberate decision, split by whether a bot returns traffic4/M
Why it matters
The useful split is not AI versus search. It is whether a crawler can send a visit or a citation in exchange for the bandwidth. Blocking a training crawler does not remove you from that company's answer engine when the citation crawler is a separate user agent.
How to verify
curl -s https://example.com/robots.txt | grep -E 'GPTBot|OAI-SearchBot|ClaudeBot|Google-Extended'Then confirm each named agent has an explicit, intentional rule.
Implementation
kit/seo/robots.ts/** * Code Kit: bot-economics robots policy. * Implements checks: crawl-robots-generated, crawl-bot-economics, * crawl-private-routes-disallowed, crawl-sitemap-declared * * The decision nobody makes deliberately: which crawlers get your content. * The useful split is not "AI bots" versus "search bots". It is whether a * crawler can send you traffic or a citation in return for the bandwidth. * * Search crawlers index you and send clicks. Allow. * Citation crawlers fetch a page to answer a live query * and name the source. Allow. * Training crawlers build a model. No link, no click, * no attribution. Your call. * * Blocking a training crawler does not remove you from that company's answer * engine if its citation crawler is a separate user agent, which for OpenAI, * Anthropic, Perplexity and Google it currently is. That asymmetry is the whole * mechanism. */ export type CrawlerClass = "search" | "citation" | "training" | "seo-tool" | "archive"; export type CrawlerProfile = { userAgent: string; operator: string; class: CrawlerClass; /** Does allowing this crawler produce visits or citations? */ sendsReferralTraffic: boolean; purpose: string; documentationUrl?: string; }; /** * Crawler registry. * * Verified against each operator's published documentation. Recheck quarterly: * user agent names change, and new ones appear faster than anyone updates * robots.txt. */ export const CRAWLERS: CrawlerProfile[] = [ { userAgent: "Googlebot", operator: "Google", class: "search", sendsReferralTraffic: true, purpose: "Google Search index. Also feeds AI Overviews.", documentationUrl: "https://developers.google.com/search/docs/crawling-indexing/overview-google-crawlers", }, { userAgent: "Bingbot", operator: "Microsoft", class: "search", sendsReferralTraffic: true, purpose: "Bing index. Also the retrieval layer behind Copilot.", documentationUrl: "https://www.bing.com/webmasters/help/which-crawlers-does-bing-use-8c184ec0", }, { userAgent: "DuckDuckBot", operator: "DuckDuckGo", class: "search", sendsReferralTraffic: true, purpose: "DuckDuckGo index.", }, { userAgent: "Applebot", operator: "Apple", class: "search", sendsReferralTraffic: true, purpose: "Siri and Spotlight suggestions.", documentationUrl: "https://support.apple.com/en-us/119829", }, { userAgent: "OAI-SearchBot", operator: "OpenAI", class: "citation", sendsReferralTraffic: true, purpose: "ChatGPT search index. Links back to the source.", documentationUrl: "https://platform.openai.com/docs/bots", }, { userAgent: "ChatGPT-User", operator: "OpenAI", class: "citation", sendsReferralTraffic: true, purpose: "Live fetch when a user asks ChatGPT about a specific URL.", documentationUrl: "https://platform.openai.com/docs/bots", }, { userAgent: "PerplexityBot", operator: "Perplexity", class: "citation", sendsReferralTraffic: true, purpose: "Perplexity answer index. Cites sources inline.", documentationUrl: "https://docs.perplexity.ai/guides/bots", }, { userAgent: "ClaudeBot", operator: "Anthropic", class: "citation", sendsReferralTraffic: true, purpose: "Claude retrieval and citation.", documentationUrl: "https://support.anthropic.com/en/articles/8896518", }, { userAgent: "Claude-User", operator: "Anthropic", class: "citation", sendsReferralTraffic: true, purpose: "Live fetch when a Claude user asks about a specific URL.", }, { userAgent: "Applebot-Extended", operator: "Apple", class: "training", sendsReferralTraffic: false, purpose: "Apple foundation model training. Opting out does not affect Siri.", documentationUrl: "https://support.apple.com/en-us/119829", }, { userAgent: "GPTBot", operator: "OpenAI", class: "training", sendsReferralTraffic: false, purpose: "Model training corpus. Separate from OAI-SearchBot.", documentationUrl: "https://platform.openai.com/docs/bots", }, { userAgent: "Google-Extended", operator: "Google", class: "training", sendsReferralTraffic: false, purpose: "Gemini training. Blocking it does not remove you from Search.", documentationUrl: "https://developers.google.com/search/docs/crawling-indexing/overview-google-crawlers", }, { userAgent: "anthropic-ai", operator: "Anthropic", class: "training", sendsReferralTraffic: false, purpose: "Legacy training crawler identifier.", }, { userAgent: "CCBot", operator: "Common Crawl", class: "training", sendsReferralTraffic: false, purpose: "Open crawl corpus used as training data by many labs.", documentationUrl: "https://commoncrawl.org/ccbot", }, { userAgent: "Bytespider", operator: "ByteDance", class: "training", sendsReferralTraffic: false, purpose: "Training corpus. Widely reported to ignore robots.txt.", }, { userAgent: "AhrefsBot", operator: "Ahrefs", class: "seo-tool", sendsReferralTraffic: false, purpose: "Backlink index. Allowing it makes your own backlink data visible to you and to competitors.", }, { userAgent: "SemrushBot", operator: "Semrush", class: "seo-tool", sendsReferralTraffic: false, purpose: "Competitive index. Same trade as AhrefsBot.", }, { userAgent: "ia_archiver", operator: "Internet Archive", class: "archive", sendsReferralTraffic: false, purpose: "Wayback Machine preservation.", }, ]; export type RobotsPolicy = { /** Crawler classes permitted to crawl. */ allowClasses: CrawlerClass[]; /** Paths every permitted crawler is denied. */ disallowPaths: string[]; /** Absolute sitemap URLs. */ sitemaps: string[]; /** Canonical host, no scheme. Bing reads this; Google ignores it. */ host?: string; /** Extra user agents to deny outright, beyond the class rules. */ denyUserAgents?: string[]; /** Emit the reasoning as comments. Leave on: people read this file. */ annotate?: boolean; crawlers?: CrawlerProfile[]; }; export const RECOMMENDED_POLICY: Pick<RobotsPolicy, "allowClasses"> = { // Allow anything that can send a visit or a citation. Deny training-only // crawlers and commercial SEO indexes, which take bandwidth and return nothing. allowClasses: ["search", "citation"], }; export type RobotsRule = { userAgent: string | string[]; allow?: string | string[]; disallow?: string | string[]; }; /** Structured output, for `app/robots.ts` in Next.js. */ export function robotsRules(policy: RobotsPolicy): RobotsRule[] { const crawlers = policy.crawlers ?? CRAWLERS; const allowed = crawlers.filter((c) => policy.allowClasses.includes(c.class)); const denied = crawlers.filter((c) => !policy.allowClasses.includes(c.class)); const denyList = [ ...denied.map((c) => c.userAgent), ...(policy.denyUserAgents ?? []), ]; const rules: RobotsRule[] = [ // The wildcard rule governs every crawler not named below, including ones // that do not exist yet. { userAgent: "*", allow: "/", disallow: policy.disallowPaths }, ]; for (const crawler of allowed) { rules.push({ userAgent: crawler.userAgent, allow: "/", disallow: policy.disallowPaths }); } for (const userAgent of denyList) { rules.push({ userAgent, disallow: "/" }); } return rules; } /** Plain text output, for a static robots.txt or the generator tool. */ export function robotsTxt(policy: RobotsPolicy): string { const crawlers = policy.crawlers ?? CRAWLERS; const annotate = policy.annotate ?? true; const lines: string[] = []; if (annotate) { lines.push( "# robots.txt", "#", "# Policy: allow any crawler that can return a visit or a citation.", "# Deny crawlers that consume bandwidth and return neither.", "#", ); } const emit = (rule: RobotsRule, comment?: string) => { if (annotate && comment) lines.push(`# ${comment}`); const agents = Array.isArray(rule.userAgent) ? rule.userAgent : [rule.userAgent]; for (const agent of agents) lines.push(`User-agent: ${agent}`); for (const allow of toArray(rule.allow)) lines.push(`Allow: ${allow}`); for (const disallow of toArray(rule.disallow)) lines.push(`Disallow: ${disallow}`); lines.push(""); }; emit( { userAgent: "*", allow: "/", disallow: policy.disallowPaths }, "Default policy for every crawler not named below.", ); for (const crawler of crawlers.filter((c) => policy.allowClasses.includes(c.class))) { emit( { userAgent: crawler.userAgent, allow: "/", disallow: policy.disallowPaths }, `${crawler.operator}: ${crawler.purpose}`, ); } const denied = crawlers.filter((c) => !policy.allowClasses.includes(c.class)); for (const crawler of denied) { emit( { userAgent: crawler.userAgent, disallow: "/" }, `${crawler.operator}: ${crawler.purpose} No referral traffic.`, ); } for (const userAgent of policy.denyUserAgents ?? []) { emit({ userAgent, disallow: "/" }); } for (const sitemap of policy.sitemaps) lines.push(`Sitemap: ${sitemap}`); if (policy.host) lines.push(`Host: ${policy.host}`); return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`; } function toArray(value: string | string[] | undefined): string[] { if (!value) return []; return Array.isArray(value) ? value : [value]; }API, auth and account routes are disallowed for every allowed agent4/S
Why it matters
A disallow under `User-agent: *` does not apply to an agent that has its own named block. Named agents inherit nothing.
How to verify
curl -s https://example.com/robots.txtThen confirm the private path list repeats under every named allowed agent, not only under the wildcard.
Implementation
kit/seo/robots.ts/** * Code Kit: bot-economics robots policy. * Implements checks: crawl-robots-generated, crawl-bot-economics, * crawl-private-routes-disallowed, crawl-sitemap-declared * * The decision nobody makes deliberately: which crawlers get your content. * The useful split is not "AI bots" versus "search bots". It is whether a * crawler can send you traffic or a citation in return for the bandwidth. * * Search crawlers index you and send clicks. Allow. * Citation crawlers fetch a page to answer a live query * and name the source. Allow. * Training crawlers build a model. No link, no click, * no attribution. Your call. * * Blocking a training crawler does not remove you from that company's answer * engine if its citation crawler is a separate user agent, which for OpenAI, * Anthropic, Perplexity and Google it currently is. That asymmetry is the whole * mechanism. */ export type CrawlerClass = "search" | "citation" | "training" | "seo-tool" | "archive"; export type CrawlerProfile = { userAgent: string; operator: string; class: CrawlerClass; /** Does allowing this crawler produce visits or citations? */ sendsReferralTraffic: boolean; purpose: string; documentationUrl?: string; }; /** * Crawler registry. * * Verified against each operator's published documentation. Recheck quarterly: * user agent names change, and new ones appear faster than anyone updates * robots.txt. */ export const CRAWLERS: CrawlerProfile[] = [ { userAgent: "Googlebot", operator: "Google", class: "search", sendsReferralTraffic: true, purpose: "Google Search index. Also feeds AI Overviews.", documentationUrl: "https://developers.google.com/search/docs/crawling-indexing/overview-google-crawlers", }, { userAgent: "Bingbot", operator: "Microsoft", class: "search", sendsReferralTraffic: true, purpose: "Bing index. Also the retrieval layer behind Copilot.", documentationUrl: "https://www.bing.com/webmasters/help/which-crawlers-does-bing-use-8c184ec0", }, { userAgent: "DuckDuckBot", operator: "DuckDuckGo", class: "search", sendsReferralTraffic: true, purpose: "DuckDuckGo index.", }, { userAgent: "Applebot", operator: "Apple", class: "search", sendsReferralTraffic: true, purpose: "Siri and Spotlight suggestions.", documentationUrl: "https://support.apple.com/en-us/119829", }, { userAgent: "OAI-SearchBot", operator: "OpenAI", class: "citation", sendsReferralTraffic: true, purpose: "ChatGPT search index. Links back to the source.", documentationUrl: "https://platform.openai.com/docs/bots", }, { userAgent: "ChatGPT-User", operator: "OpenAI", class: "citation", sendsReferralTraffic: true, purpose: "Live fetch when a user asks ChatGPT about a specific URL.", documentationUrl: "https://platform.openai.com/docs/bots", }, { userAgent: "PerplexityBot", operator: "Perplexity", class: "citation", sendsReferralTraffic: true, purpose: "Perplexity answer index. Cites sources inline.", documentationUrl: "https://docs.perplexity.ai/guides/bots", }, { userAgent: "ClaudeBot", operator: "Anthropic", class: "citation", sendsReferralTraffic: true, purpose: "Claude retrieval and citation.", documentationUrl: "https://support.anthropic.com/en/articles/8896518", }, { userAgent: "Claude-User", operator: "Anthropic", class: "citation", sendsReferralTraffic: true, purpose: "Live fetch when a Claude user asks about a specific URL.", }, { userAgent: "Applebot-Extended", operator: "Apple", class: "training", sendsReferralTraffic: false, purpose: "Apple foundation model training. Opting out does not affect Siri.", documentationUrl: "https://support.apple.com/en-us/119829", }, { userAgent: "GPTBot", operator: "OpenAI", class: "training", sendsReferralTraffic: false, purpose: "Model training corpus. Separate from OAI-SearchBot.", documentationUrl: "https://platform.openai.com/docs/bots", }, { userAgent: "Google-Extended", operator: "Google", class: "training", sendsReferralTraffic: false, purpose: "Gemini training. Blocking it does not remove you from Search.", documentationUrl: "https://developers.google.com/search/docs/crawling-indexing/overview-google-crawlers", }, { userAgent: "anthropic-ai", operator: "Anthropic", class: "training", sendsReferralTraffic: false, purpose: "Legacy training crawler identifier.", }, { userAgent: "CCBot", operator: "Common Crawl", class: "training", sendsReferralTraffic: false, purpose: "Open crawl corpus used as training data by many labs.", documentationUrl: "https://commoncrawl.org/ccbot", }, { userAgent: "Bytespider", operator: "ByteDance", class: "training", sendsReferralTraffic: false, purpose: "Training corpus. Widely reported to ignore robots.txt.", }, { userAgent: "AhrefsBot", operator: "Ahrefs", class: "seo-tool", sendsReferralTraffic: false, purpose: "Backlink index. Allowing it makes your own backlink data visible to you and to competitors.", }, { userAgent: "SemrushBot", operator: "Semrush", class: "seo-tool", sendsReferralTraffic: false, purpose: "Competitive index. Same trade as AhrefsBot.", }, { userAgent: "ia_archiver", operator: "Internet Archive", class: "archive", sendsReferralTraffic: false, purpose: "Wayback Machine preservation.", }, ]; export type RobotsPolicy = { /** Crawler classes permitted to crawl. */ allowClasses: CrawlerClass[]; /** Paths every permitted crawler is denied. */ disallowPaths: string[]; /** Absolute sitemap URLs. */ sitemaps: string[]; /** Canonical host, no scheme. Bing reads this; Google ignores it. */ host?: string; /** Extra user agents to deny outright, beyond the class rules. */ denyUserAgents?: string[]; /** Emit the reasoning as comments. Leave on: people read this file. */ annotate?: boolean; crawlers?: CrawlerProfile[]; }; export const RECOMMENDED_POLICY: Pick<RobotsPolicy, "allowClasses"> = { // Allow anything that can send a visit or a citation. Deny training-only // crawlers and commercial SEO indexes, which take bandwidth and return nothing. allowClasses: ["search", "citation"], }; export type RobotsRule = { userAgent: string | string[]; allow?: string | string[]; disallow?: string | string[]; }; /** Structured output, for `app/robots.ts` in Next.js. */ export function robotsRules(policy: RobotsPolicy): RobotsRule[] { const crawlers = policy.crawlers ?? CRAWLERS; const allowed = crawlers.filter((c) => policy.allowClasses.includes(c.class)); const denied = crawlers.filter((c) => !policy.allowClasses.includes(c.class)); const denyList = [ ...denied.map((c) => c.userAgent), ...(policy.denyUserAgents ?? []), ]; const rules: RobotsRule[] = [ // The wildcard rule governs every crawler not named below, including ones // that do not exist yet. { userAgent: "*", allow: "/", disallow: policy.disallowPaths }, ]; for (const crawler of allowed) { rules.push({ userAgent: crawler.userAgent, allow: "/", disallow: policy.disallowPaths }); } for (const userAgent of denyList) { rules.push({ userAgent, disallow: "/" }); } return rules; } /** Plain text output, for a static robots.txt or the generator tool. */ export function robotsTxt(policy: RobotsPolicy): string { const crawlers = policy.crawlers ?? CRAWLERS; const annotate = policy.annotate ?? true; const lines: string[] = []; if (annotate) { lines.push( "# robots.txt", "#", "# Policy: allow any crawler that can return a visit or a citation.", "# Deny crawlers that consume bandwidth and return neither.", "#", ); } const emit = (rule: RobotsRule, comment?: string) => { if (annotate && comment) lines.push(`# ${comment}`); const agents = Array.isArray(rule.userAgent) ? rule.userAgent : [rule.userAgent]; for (const agent of agents) lines.push(`User-agent: ${agent}`); for (const allow of toArray(rule.allow)) lines.push(`Allow: ${allow}`); for (const disallow of toArray(rule.disallow)) lines.push(`Disallow: ${disallow}`); lines.push(""); }; emit( { userAgent: "*", allow: "/", disallow: policy.disallowPaths }, "Default policy for every crawler not named below.", ); for (const crawler of crawlers.filter((c) => policy.allowClasses.includes(c.class))) { emit( { userAgent: crawler.userAgent, allow: "/", disallow: policy.disallowPaths }, `${crawler.operator}: ${crawler.purpose}`, ); } const denied = crawlers.filter((c) => !policy.allowClasses.includes(c.class)); for (const crawler of denied) { emit( { userAgent: crawler.userAgent, disallow: "/" }, `${crawler.operator}: ${crawler.purpose} No referral traffic.`, ); } for (const userAgent of policy.denyUserAgents ?? []) { emit({ userAgent, disallow: "/" }); } for (const sitemap of policy.sitemaps) lines.push(`Sitemap: ${sitemap}`); if (policy.host) lines.push(`Host: ${policy.host}`); return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`; } function toArray(value: string | string[] | undefined): string[] { if (!value) return []; return Array.isArray(value) ? value : [value]; }robots.txt declares the sitemap and the canonical host3/S
Why it matters
The Sitemap directive is how a crawler finds the sitemap without you submitting it anywhere. Host is read by Bing and ignored by Google, which costs nothing.
How to verify
curl -s https://example.com/robots.txt | grep -i '^sitemap:'Then confirm an absolute URL.
Implementation snippet
The tested implementation from kit/seo/robots.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.The sitemap is composed from the same data the pages render from4/M
Why it matters
A hardcoded URL list in a sitemap goes stale the first time a route is added. Composing from the content modules makes the sitemap a projection of reality rather than a claim about it.
How to verify
Add a page, rebuild, and confirm it appears without editing the sitemap: `curl -s https://example.com/sitemap.xml | grep -c '<loc>'`.
Implementation
kit/seo/sitemap.ts/** * Code Kit: sitemap composer. * Implements checks: crawl-sitemap-generated, crawl-lastmodified-differentiated, * crawl-priority-ladder, crawl-hreflang, crawl-sitemap-integrity * * The failure this module exists to prevent: one build timestamp stamped on * every URL. A sitemap that claims 2,100 pages changed at 03:14 this morning is * worse than a sitemap with no lastModified at all, because the crawler learns * the field is noise and stops reading it. Every entry here carries a date that * came from the content. */ export type ChangeFrequency = | "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never"; export type SitemapEntry = { url: string; lastModified: Date; changeFrequency?: ChangeFrequency; priority?: number; alternates?: { languages: Record<string, string> }; }; export type RouteGroup<T> = { /** Group name, used in the integrity report. */ name: string; items: T[]; /** Absolute or site-relative URL for an item. */ url: (item: T) => string; /** Real date from the content. Never `new Date()`. */ lastModified: (item: T) => Date | string; priority: number; changeFrequency: ChangeFrequency; }; /** * Priority ladder. * * Priority is a relative hint within one site, not a ranking input. Its only * real job is telling a crawler which URLs to revisit first when it has a * limited budget. Flat 0.8 on everything communicates nothing. */ export const PRIORITY = { home: 1.0, primaryConversion: 0.95, primaryContent: 0.9, hub: 0.85, programmatic: 0.8, editorial: 0.7, reference: 0.6, taxonomy: 0.5, legal: 0.3, } as const; export type SitemapOptions = { siteUrl: string; /** Alternate language codes to emit per URL. Include x-default. */ languages?: string[]; }; export function buildSitemap( groups: Array<RouteGroup<never> | RouteGroup<unknown>>, opts: SitemapOptions, ): SitemapEntry[] { const origin = opts.siteUrl.replace(/\/+$/, ""); const entries: SitemapEntry[] = []; const seen = new Set<string>(); for (const group of groups as Array<RouteGroup<unknown>>) { for (const item of group.items) { const raw = group.url(item); const url = raw.startsWith("http") ? raw : `${origin}${raw.startsWith("/") ? "" : "/"}${raw}`; const normalised = url.replace(/(?<!:)\/{2,}/g, "/").replace(/\/$/, "") || origin; if (seen.has(normalised)) continue; seen.add(normalised); const lastModifiedRaw = group.lastModified(item); const lastModified = lastModifiedRaw instanceof Date ? lastModifiedRaw : new Date(lastModifiedRaw); entries.push({ url: normalised, lastModified: Number.isNaN(lastModified.getTime()) ? new Date(0) : lastModified, changeFrequency: group.changeFrequency, priority: group.priority, ...(opts.languages?.length ? { alternates: { languages: Object.fromEntries( opts.languages.map((lang) => [lang, normalised]), ), }, } : {}), }); } } return entries; } /** * Integrity report. Run this in a test so a sitemap regression fails the build * rather than quietly shipping. */ export function sitemapIntegrity(entries: SitemapEntry[], siteUrl: string) { const origin = siteUrl.replace(/\/+$/, ""); const urls = entries.map((entry) => entry.url); const duplicates = urls.filter((url, index) => urls.indexOf(url) !== index); const offOrigin = urls.filter((url) => !url.startsWith(origin)); const trailingSlash = urls.filter((url) => url !== origin && url.endsWith("/")); const epochDates = entries.filter((entry) => entry.lastModified.getTime() === 0); const futureDates = entries.filter((entry) => entry.lastModified.getTime() > Date.now() + 86_400_000); // If more than a quarter of URLs share one timestamp, lastModified is a build // stamp rather than a content date. const byTimestamp = new Map<number, number>(); for (const entry of entries) { const day = Math.floor(entry.lastModified.getTime() / 86_400_000); byTimestamp.set(day, (byTimestamp.get(day) ?? 0) + 1); } const largestCluster = Math.max(0, ...byTimestamp.values()); const clusteredRatio = entries.length > 0 ? largestCluster / entries.length : 0; return { total: entries.length, duplicates, offOrigin, trailingSlash, epochDates: epochDates.map((entry) => entry.url), futureDates: futureDates.map((entry) => entry.url), clusteredRatio, distinctDays: byTimestamp.size, ok: duplicates.length === 0 && offOrigin.length === 0 && trailingSlash.length === 0 && epochDates.length === 0 && futureDates.length === 0, }; } /** Serialise to XML for a hand-rolled route or a static file. */ export function sitemapXml(entries: SitemapEntry[]): string { const urls = entries .map((entry) => { const alternates = entry.alternates?.languages ? Object.entries(entry.alternates.languages) .map( ([lang, href]) => ` <xhtml:link rel="alternate" hreflang="${escapeXml(lang)}" href="${escapeXml(href)}" />`, ) .join("\n") : ""; return [ " <url>", ` <loc>${escapeXml(entry.url)}</loc>`, ` <lastmod>${entry.lastModified.toISOString()}</lastmod>`, entry.changeFrequency ? ` <changefreq>${entry.changeFrequency}</changefreq>` : "", entry.priority !== undefined ? ` <priority>${entry.priority.toFixed(2)}</priority>` : "", alternates, " </url>", ] .filter(Boolean) .join("\n"); }) .join("\n"); return [ '<?xml version="1.0" encoding="UTF-8"?>', '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">', urls, "</urlset>", "", ].join("\n"); } export function escapeXml(value: string): string { return value .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }lastModified comes from content dates, never from the build timestamp4/M
Why it matters
A sitemap claiming 2,100 pages changed at 03:14 this morning teaches the crawler the field is noise. It then stops reading it, and you lose the signal permanently.
How to verify
curl -s https://example.com/sitemap.xml | grep -o '<lastmod>[^<]*' | cut -c10-19 | sort | uniq -c | sort -rn | headThen confirm no single date covers most of the URLs.
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 differentiated priority ladder, not 0.8 on everything2/S
Why it matters
Priority is a relative hint inside one site. Its only real job is telling a crawler with a limited budget which URLs to revisit first, and a flat value communicates nothing.
How to verify
curl -s https://example.com/sitemap.xml | grep -o '<priority>[^<]*' | sort | uniq -cThen confirm at least four distinct values.
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.hreflang alternates are emitted per URL in the sitemap2/S
Why it matters
Sitemap-level hreflang scales better than tag-level for a large site, and it is the only place to declare it for non-HTML resources.
How to verify
curl -s https://example.com/sitemap.xml | grep -c 'xhtml:link'Then confirm a non-zero count.
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 build-time test fails on duplicate, off-origin or trailing-slash sitemap URLs3/M
Why it matters
A sitemap containing a URL that redirects, 404s, or points at another host wastes crawl budget and lowers trust in the whole file. It is trivially caught by a test and almost never tested.
How to verify
Run the sitemap integrity suite: `npm run test -- sitemap`. It must fail the build, not warn.
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.An RSS feed generated from the same source as the sitemap2/S
Why it matters
Feeds still drive real distribution through readers, aggregators and newsletter tools. Generating from one source means the two cannot disagree about what exists.
How to verify
curl -s https://example.com/feed.xml | head -5Then validate at validator.w3.org/feed. Zero errors, not just zero fatal errors.
Implementation snippet
The tested implementation from kit/seo/feeds.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Every interpolated value in the feed is XML-escaped2/S
Why it matters
One unescaped ampersand in a title invalidates the whole document, and most readers fail closed rather than skipping the item.
How to verify
Publish a post with `&`, `<` and `"` in the title, then run `curl -s https://example.com/feed.xml | xmllint --noout -` and confirm no error.
Implementation snippet
The tested implementation from kit/seo/feeds.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.The feed is declared in the head and linked from the footer1/S
Why it matters
An undeclared feed is only found by people who guess the URL. The alternate link is what feed readers auto-discover.
How to verify
curl -s https://example.com/ | grep 'application/rss+xml'Then confirm a link element with rel=alternate.
Implementation snippet
The tested implementation from kit/seo/feeds.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.IndexNow batch submission on a schedule2/M
Why it matters
It is the only push mechanism that exists. Everything else is polling. Bing, Yandex, Naver and Seznam participate; Google does not.
How to verify
Trigger the cron route with a valid token and confirm a 200 or 202 from api.indexnow.org in the response body.
Implementation snippet
The tested implementation from kit/seo/indexnow.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.The IndexNow key file is served through a constrained rewrite, not committed3/S
Why it matters
A key committed to a public repo lets anyone submit URLs on your host. Constraining the rewrite to a 32-hex-character pattern also stops the route being used to probe for arbitrary files.
How to verify
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/not-a-key.txtThen confirm 404, then confirm the real key path returns 200.
Implementation snippet
The tested implementation from kit/seo/indexnow.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.No indexable URL is reachable only from the sitemap4/M
Why it matters
A page with no internal links pointing at it gets crawled rarely and ranks poorly, whatever the sitemap says. The sitemap is a hint; links are the graph.
How to verify
Crawl the site from the home page and diff the discovered URL set against the sitemap set. Anything in the sitemap and not in the crawl is orphaned.
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.
Programmatic pages
Generating hundreds or thousands of pages without producing doorway pages. The gate matters more than the template.
Every programmatic route is gated against an authoritative entity list5/M
Why it matters
An ungated dynamic route generates a page for any slug anyone types, including slugs an attacker generates. That is how a site accidentally publishes ten thousand thin pages.
How to verify
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/glossary/not-a-real-termThen confirm 404, not 200.
Implementation
kit/pseo/gating.ts/** * Code Kit: entity gating. * Implements checks: pseo-entity-gate, found-404-returns-404 * * An ungated dynamic route renders a page for any slug it is given, including * slugs a crawler invented by mangling a URL. Each one is an indexable page * with no content. This module is the gate. */ export type GatedEntity = { slug: string }; export type EntityGate<T extends GatedEntity> = { /** Every valid slug, for static generation. */ slugs: () => string[]; /** Returns undefined for anything not on the list. Never a partial match. */ resolve: (slug: string) => T | undefined; /** Canonical slug to redirect to, or undefined. Aliases never render. */ resolveAlias: (slug: string) => string | undefined; all: () => readonly T[]; size: number; }; export function createEntityGate<T extends GatedEntity>( entities: readonly T[], aliases: Readonly<Record<string, string>> = {}, ): EntityGate<T> { const bySlug = new Map(entities.map((entity) => [entity.slug, entity])); // A duplicate slug means two entities compete for one URL. Fail loudly at // module load rather than serving whichever one won the Map insertion. if (bySlug.size !== entities.length) { const seen = new Set<string>(); const duplicates = entities .map((entity) => entity.slug) .filter((slug) => (seen.has(slug) ? true : (seen.add(slug), false))); throw new Error(`Duplicate entity slugs: ${[...new Set(duplicates)].join(", ")}`); } return { slugs: () => entities.map((entity) => entity.slug), resolve: (slug) => bySlug.get(slug), resolveAlias: (slug) => { const target = aliases[slug]; return target && bySlug.has(target) ? target : undefined; }, all: () => entities, size: entities.length, }; } /** * Slug normalisation for the URLs you generate, not for the ones you receive. * Receiving a slug that needs normalising means the link that produced it is * wrong; fix the link rather than accepting both forms. */ export function toSlug(value: string): string { return value .toLowerCase() .normalize("NFKD") .replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); }Apply the strip-the-entity-name test before publishing a tier5/S
Why it matters
Remove the entity name from the page. If nothing unique remains, the tier is doorway pages and shipping it risks the whole domain, not just the tier.
How to verify
Take three pages from the tier, delete every occurrence of the entity name, and diff them. If the diff is empty, do not publish.
Each page carries numbers computed from its own entity5/L
Why it matters
Numbers are the cheapest form of genuine uniqueness and the hardest to fake at scale. A page with six entity-specific figures cannot be a template with the name swapped.
How to verify
Pick five random pages in the tier and confirm at least four numeric values differ across all five.
Implementation snippet
The tested implementation from kit/pseo/facts.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.FAQs are computed per entity, not templated with a name substitution4/L
Why it matters
A per-entity FAQ answer that contains a real figure is quotable by an answer engine. The same sentence with only the name changed is recognisably generated.
How to verify
Compare the FAQ answers on two pages in the tier. If they differ only in the entity name, they are not computed.
Implementation snippet
The tested implementation from kit/pseo/facts.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Missing data renders an honest label, never a fabricated value3/M
Why it matters
A template that prints 0 or an em dash when data is missing is asserting something false. Labelling the gap is both accurate and, unexpectedly, a trust signal.
How to verify
Find an entity with incomplete source data and confirm the page says so in words rather than rendering a zero.
Implementation snippet
The tested implementation from kit/pseo/facts.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Each page links up to its hub, sideways to siblings, and across to related content4/M
Why it matters
A flat tier of 1,689 pages linked only from a paginated index gets crawled once. Sibling links turn it into a connected graph a crawler can traverse.
How to verify
On any tier page, count internal links to the same tier and confirm at least six, plus one to the hub.
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.Generated data is committed as typed source, with a stamped update time3/M
Why it matters
Querying a database at build time makes the build non-reproducible and couples deploys to database availability. Committed snapshots make a data change a reviewable diff.
How to verify
Confirm the generated module exports a `*_UPDATED_AT` constant and that the file is in version control.
Implementation snippet
The tested implementation from kit/pseo/generate.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Every programmatic URL is prerendered, not rendered on demand3/M
Why it matters
A tier rendered on request depends on the database being up for the sales funnel to work, and pays a cold-start cost on the first crawl of every page.
How to verify
Check the build output and confirm the tier routes are listed as static, then confirm a request to a tier page returns a cache hit.
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.
Content and E-E-A-T
Authorship, freshness and the signals that separate a page written by someone who did the work from one written to rank.
A real author registry with alias resolution, and no fabricated credentials3/M
Why it matters
Author entities are a genuine trust signal and a fabricated one is a liability. Alias resolution is what stops three spellings of the same name becoming three authors.
How to verify
Confirm every byline on the site resolves to one entry in the author registry, and that every claim in a bio is verifiable.
Every chapter and post shows a last-reviewed date that is actually maintained3/S
Why it matters
A reviewed date is a promise. Bumping it without reviewing is worse than not showing one, because it converts a trust signal into a false claim.
How to verify
Confirm the rendered date comes from frontmatter, and that a documented review process exists with a cadence.
Every non-obvious claim maps to code, official documentation, or an explicit opinion label3/M
Why it matters
This is both an integrity guard and defensible marketing. It is also the only way to keep a large document accurate as the underlying platforms change.
How to verify
Open CLAIMS.md and confirm each entry names a file, a documentation URL, or the label opinion. Spot-check five.
The site publishes what it still gets wrong3/S
Why it matters
A proof page showing 100 percent on everything is less credible than one showing 94 percent and naming the six misses. Honesty about limits is the strongest available trust signal when you have no testimonials.
How to verify
Confirm the public proof page lists current failures, with dates, and that the list is not empty.
Cross-links between chapters, checks and glossary terms run in both directions3/M
Why it matters
One-directional linking creates a hierarchy the crawler descends and never climbs. Bidirectional linking distributes authority across the whole reference rather than pooling it at the top.
How to verify
Pick a chapter, follow its link to a check, and confirm the check links back to that chapter.
No invented testimonials, no fabricated review counts, no aggregateRating without reviews3/S
Why it matters
An aggregateRating with no reviews behind it is a manual action risk and a lie. Real numbers from the work are stronger proof than fake numbers from customers who do not exist.
How to verify
curl -s https://example.com/ | grep -c aggregateRatingThen confirm 0 until real, verifiable reviews exist.
Implementation
kit/seo/schema/commerce.ts/** * Code Kit: commerce schema factories. * Implements checks: schema-product, schema-offer, schema-softwareapplication, * schema-service, pricing-single-source */ import { compact } from "../constants"; import { type JsonLdObject, type NodeRef, nodeId, ref } from "./types"; export type OfferOptions = { url: string; priceCents: number; currency?: string; name?: string; description?: string; availability?: "InStock" | "OutOfStock" | "PreOrder" | "LimitedAvailability"; /** ISO date. Google warns when a price has no validity window. */ priceValidUntil?: string; seller?: NodeRef; /** One-time purchase by default. Set for anything recurring. */ billingDuration?: { value: number; unit: "MON" | "ANN" }; }; function formatPrice(cents: number): string { return (cents / 100).toFixed(2); } export function offer(opts: OfferOptions): JsonLdObject { const price = formatPrice(opts.priceCents); const currency = opts.currency ?? "USD"; return compact({ "@type": "Offer", url: opts.url, name: opts.name, description: opts.description, price, priceCurrency: currency, availability: `https://schema.org/${opts.availability ?? "InStock"}`, priceValidUntil: opts.priceValidUntil, seller: opts.seller, priceSpecification: compact({ "@type": "UnitPriceSpecification", price, priceCurrency: currency, valueAddedTaxIncluded: true, billingDuration: opts.billingDuration?.value, billingIncrement: opts.billingDuration ? 1 : undefined, unitCode: opts.billingDuration?.unit, }), }) as JsonLdObject; } export type AggregateOfferOptions = { url: string; lowPriceCents: number; highPriceCents: number; currency?: string; offerCount: number; offers?: JsonLdObject[]; seller?: NodeRef; }; export function aggregateOffer(opts: AggregateOfferOptions): JsonLdObject { return compact({ "@type": "AggregateOffer", url: opts.url, lowPrice: formatPrice(opts.lowPriceCents), highPrice: formatPrice(opts.highPriceCents), priceCurrency: opts.currency ?? "USD", offerCount: opts.offerCount, availability: "https://schema.org/InStock", seller: opts.seller, offers: opts.offers?.length ? opts.offers : undefined, }) as JsonLdObject; } export type ProductOptions = { siteUrl: string; name: string; description: string; url: string; imageUrl?: string; brandName?: string; category?: string; sku?: string; offers?: JsonLdObject; /** * Ratings are omitted entirely until real, verifiable reviews exist. * Inventing an aggregateRating is a manual action risk and a lie. */ aggregateRating?: { ratingValue: number; reviewCount: number }; id?: string; }; export function product(opts: ProductOptions): JsonLdObject { return compact({ "@type": "Product", "@id": opts.id ?? nodeId(opts.siteUrl, "#product"), name: opts.name, description: opts.description, url: opts.url, image: opts.imageUrl, sku: opts.sku, category: opts.category, brand: opts.brandName ? { "@type": "Brand", name: opts.brandName } : ref(nodeId(opts.siteUrl, "#organization")), offers: opts.offers, aggregateRating: opts.aggregateRating ? { "@type": "AggregateRating", ratingValue: opts.aggregateRating.ratingValue, reviewCount: opts.aggregateRating.reviewCount, } : undefined, }) as JsonLdObject; } export type SoftwareApplicationOptions = { name: string; description: string; url: string; applicationCategory?: string; operatingSystem?: string; /** Pass a free Offer for a free tool; omit for a paid product. */ offers?: JsonLdObject; featureList?: string[]; softwareVersion?: string; id?: string; }; export function softwareApplication(opts: SoftwareApplicationOptions): JsonLdObject { return compact({ "@type": "SoftwareApplication", "@id": opts.id ?? `${opts.url}#software`, name: opts.name, description: opts.description, url: opts.url, applicationCategory: opts.applicationCategory ?? "DeveloperApplication", operatingSystem: opts.operatingSystem ?? "Web browser", softwareVersion: opts.softwareVersion, featureList: opts.featureList?.length ? opts.featureList : undefined, offers: opts.offers, }) as JsonLdObject; } export type ServiceOptions = { name: string; description: string; url: string; serviceType?: string; areaServed?: string; provider?: NodeRef; offers?: JsonLdObject; }; export function service(opts: ServiceOptions): JsonLdObject { return compact({ "@type": "Service", "@id": `${opts.url}#service`, name: opts.name, description: opts.description, url: opts.url, serviceType: opts.serviceType, areaServed: opts.areaServed, provider: opts.provider, offers: opts.offers, }) as JsonLdObject; }
AI and answer engines
The newest surface and the least codified. llms.txt, citation guidance, and answers written so a single extracted sentence still stands alone.
llms.txt with an entity definition, key facts and explicit negative statements3/M
Why it matters
The negative statements block is the most useful part: it is how you stop an engine conflating you with a similarly named product. The file costs one route.
How to verify
curl -s https://example.com/llms.txt | head -30Then confirm an entity paragraph, a key-facts list and a 'what this is not' section.
Implementation
kit/seo/llms.ts/** * Code Kit: llms.txt and llms-full.txt builders. * Implements checks: aeo-llms-txt, aeo-llms-full-txt, aeo-canonical-answers, * aeo-entity-disambiguation, aeo-citation-guidance * * llms.txt is a proposed convention, not a standard any engine is obliged to * read. Ship it anyway: it costs one route, it is machine-readable, and the * discipline of writing a canonical answer per likely question improves the * visible page copy as a side effect. * * The rule that matters more than the file: every answer is a self-contained * sentence that restates its subject. "Acme covers 70 checks" survives being * extracted alone. "We cover 70 checks" does not. */ export type KeyFact = { label: string; value: string }; export type LinkSection = { title: string; /** Optional one-line description of what this section contains. */ note?: string; links: Array<{ title: string; url: string; description?: string }>; }; export type LlmsTxtOptions = { brandName: string; siteUrl: string; /** One paragraph. What this entity is, in the third person. */ entityDefinition: string; keyFacts: KeyFact[]; /** * What this is NOT. The single most useful block in the file: it is how you * stop an engine conflating you with a similarly named product. */ negativeStatements: string[]; sections: LinkSection[]; lastReviewed: string; }; export function buildLlmsTxt(opts: LlmsTxtOptions): string { const lines: string[] = [ `# ${opts.brandName}`, "", `> ${opts.entityDefinition}`, "", "## Key facts", "", ...opts.keyFacts.map((fact) => `- ${fact.label}: ${fact.value}`), "", "## What this is not", "", ...opts.negativeStatements.map((statement) => `- ${statement}`), "", ]; for (const section of opts.sections) { lines.push(`## ${section.title}`, ""); if (section.note) lines.push(section.note, ""); for (const link of section.links) { lines.push( `- [${link.title}](${absolute(link.url, opts.siteUrl)})${link.description ? `: ${link.description}` : ""}`, ); } lines.push(""); } lines.push("## Metadata", "", `- Last reviewed: ${opts.lastReviewed}`, `- Canonical site: ${opts.siteUrl}`, ""); return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`; } export type CanonicalAnswer = { question: string; /** Self-contained. Restates the subject. Survives extraction alone. */ answer: string; sourceUrl: string; /** Set for anything that changes, such as pricing. */ volatile?: boolean; }; export type ContentIndexEntry = { category: string; title: string; url: string; summary: string; }; export type LlmsFullTxtOptions = LlmsTxtOptions & { contentIndex: ContentIndexEntry[]; canonicalAnswers: CanonicalAnswer[]; /** Things this entity is commonly confused with, and how to tell them apart. */ disambiguation: Array<{ confusedWith: string; distinction: string }>; citationGuidance: { preferredUrl: string; /** How an assistant should qualify facts that go stale. */ volatileFactPolicy: string; /** What to do when the answer is not in the index. */ uncertaintyPolicy: string; attributionLine: string; }; }; export function buildLlmsFullTxt(opts: LlmsFullTxtOptions): string { const lines: string[] = [ `# ${opts.brandName}: full content index`, "", `> ${opts.entityDefinition}`, "", "## Key facts", "", ...opts.keyFacts.map((fact) => `- ${fact.label}: ${fact.value}`), "", "## What this is not", "", ...opts.negativeStatements.map((statement) => `- ${statement}`), "", "## Entity disambiguation", "", ...opts.disambiguation.map( (entry) => `- Not to be confused with ${entry.confusedWith}. ${entry.distinction}`, ), "", "## Content index", "", ]; const byCategory = new Map<string, ContentIndexEntry[]>(); for (const entry of opts.contentIndex) { const bucket = byCategory.get(entry.category) ?? []; bucket.push(entry); byCategory.set(entry.category, bucket); } for (const [category, entries] of byCategory) { lines.push(`### ${category}`, ""); for (const entry of entries) { lines.push(`- [${entry.title}](${absolute(entry.url, opts.siteUrl)}): ${entry.summary}`); } lines.push(""); } lines.push( "## Canonical answers", "", "Each answer below is approved for quotation. Cite the source URL alongside it.", "", ); for (const qa of opts.canonicalAnswers) { lines.push( `### ${qa.question}`, "", qa.answer, "", `Source: ${absolute(qa.sourceUrl, opts.siteUrl)}`, qa.volatile ? "Note: this fact changes. Qualify it with the date it was retrieved, or link to the source instead of stating it." : "", "", ); } lines.push( "## Citation guidance", "", `- Preferred URL to cite: ${opts.citationGuidance.preferredUrl}`, `- Volatile facts: ${opts.citationGuidance.volatileFactPolicy}`, `- When uncertain: ${opts.citationGuidance.uncertaintyPolicy}`, `- Attribution: ${opts.citationGuidance.attributionLine}`, "", "## Metadata", "", `- Last reviewed: ${opts.lastReviewed}`, `- Canonical site: ${opts.siteUrl}`, "", ); return `${lines.filter((line) => line !== undefined).join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`; } export const LLMS_TXT_HEADERS = { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400", } as const; function absolute(url: string, siteUrl: string): string { if (/^https?:\/\//i.test(url)) return url; const origin = siteUrl.replace(/\/+$/, ""); return `${origin}${url.startsWith("/") ? "" : "/"}${url}`; }llms-full.txt with a categorised content index and one-line summaries3/M
Why it matters
It gives a retrieval system the shape of the site in one fetch instead of requiring it to crawl and infer structure.
How to verify
curl -s https://example.com/llms-full.txt | grep -c '^- \['Then confirm the count is close to the number of indexable content URLs.
Implementation snippet
The tested implementation from kit/seo/llms.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.A canonical Q&A block pairing each likely question with an approved answer and its source URL4/M
Why it matters
If you do not write the answer, the engine writes it from whatever fragment it found. This is the cheapest control you have over how you are described.
How to verify
Confirm the canonical answers section exists and that each answer carries a Source line pointing at a real URL.
Implementation snippet
The tested implementation from kit/seo/llms.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Answers are self-contained sentences that restate their subject4/M
Why it matters
An extracted sentence has to stand alone as a citable fact. 'Acme covers 70 checks' survives extraction; 'we cover 70 checks' does not, because the subject is lost.
How to verify
Take any FAQ answer out of context and read it. If it starts with we, it, this or that, rewrite it.
Explicit citation guidance: which URL to cite, how to qualify volatile facts, where to defer3/S
Why it matters
Pricing changes. A model that cached a price and states it flatly is doing damage. Telling it to link rather than assert is the fix, and nobody else is writing this down.
How to verify
Confirm llms-full.txt contains a citation guidance section naming a preferred URL and a volatile-fact policy.
Implementation snippet
The tested implementation from kit/seo/llms.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.An entity disambiguation section naming what you are commonly confused with3/S
Why it matters
Entity confusion is the single most common AEO failure and the easiest to prevent. One sentence per confusable entity.
How to verify
Confirm llms-full.txt has a disambiguation section with at least two entries.
Implementation snippet
The tested implementation from kit/seo/llms.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.A monthly citation check against a fixed set of seed queries3/M
Why it matters
AEO is the boldest claim most sites now make and almost nobody measures it. A fixed query set run monthly turns the claim into a trend line.
How to verify
Run the citation check script and confirm it writes a dated JSON snapshot naming which engines cited the site.
Performance
Core Web Vitals as a ranking input and, more importantly, as the thing that decides whether a mobile visitor stays.
LCP under 1.8 seconds on mobile for every template4/M
Why it matters
LCP is the Core Web Vital most often failed and the one most visible to a visitor. It is also the one a marketing page can control almost entirely at build time.
How to verify
npx lighthouse https://example.com --preset=perf --form-factor=mobile --screenEmulation.mobile --only-categories=performance --output=json | jq '.audits["largest-contentful-paint"].numericValue'.
Route CSS is inlined so it does not block first paint3/S
Why it matters
An external stylesheet is a render-blocking round trip before anything paints. Inlining the route's CSS removes it and is worth roughly 200ms on a cold mobile connection.
How to verify
curl -s https://example.com/ | grep -c '<link rel="stylesheet"'Then confirm 0, then confirm a style element is present in the head.
Implementation snippet
The tested implementation from kit/config/next.config.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Fonts are self-hosted with display swap and subsetting, and zero external font requests3/S
Why it matters
A Google Fonts link costs a DNS lookup, a TLS handshake and a render-blocking request on the critical path, before any character appears.
How to verify
curl -s https://example.com/ | grep -E 'fonts.googleapis|fonts.gstatic'Then confirm the result is empty.
Images served as AVIF or WebP with correct sizes and a long cache TTL4/M
Why it matters
AVIF is typically half the bytes of a comparable JPEG. Serving a 1600px image into a 375px viewport is the most common single cause of a failed mobile LCP.
How to verify
Load the page in DevTools, filter to images, and confirm the content-type is image/avif or image/webp and no image is served larger than twice its displayed width.
Implementation snippet
The tested implementation from kit/config/next.config.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Non-critical interactive components are code-split3/M
Why it matters
An exit-intent modal that ships in the main bundle costs every visitor the bytes for a component most of them never see.
How to verify
Check the build output for separate chunks for the modal, any validator and any chart, and confirm they are not in the shared entry chunk.
Animation libraries are imported through their minimal namespace on marketing pages3/S
Why it matters
The full Framer Motion import is large enough on its own to cost a page its performance score. The lazy namespace ships a fraction of it.
How to verify
grep -rn 'from "framer-motion"' components app | grep -v '\bm\b'Then confirm no marketing component imports the full motion namespace.
Static assets carry a one-year immutable cache header, with versioned URLs2/S
Why it matters
Immutable caching removes the revalidation request entirely. It is only safe if every asset URL changes when its content does, which is why the version query is part of the same check.
How to verify
curl -sI https://example.com/icon-192.png | grep -i cache-controlThen confirm max-age=31536000 and immutable.
Implementation snippet
The tested implementation from kit/config/headers.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.No layout shift from the sticky CTA, the cookie banner or web fonts3/M
Why it matters
These three are the most common CLS sources on a marketing site because each one appears after first paint by design.
How to verify
Run Lighthouse mobile and confirm cumulative-layout-shift is under 0.1, then confirm the banner reserves its space or is fixed-position.
Every marketing and programmatic page is prerendered, so a database outage cannot take down the funnel4/M
Why it matters
If the sales page needs the database to render, an outage stops revenue rather than degrading a feature. Static-first makes the funnel independent of the app.
How to verify
Stop the database, request the landing page and the pricing page, and confirm both still return 200 with full content.
Trust, security and accessibility
Headers, policy pages and keyboard operability. Partly ranking signals, mostly the difference between a site people trust and one they bounce from.
HSTS with a long max-age, includeSubDomains and preload2/S
Why it matters
Without it the first request of every session is plain http and redirectable. With preload the browser never makes that request at all.
How to verify
curl -sI https://example.com | grep -i strict-transport.
Implementation
kit/config/headers.ts/** * Code Kit: security and cache headers. * Implements checks: sec-hsts, sec-nosniff, sec-frame-deny, sec-referrer-policy, * sec-permissions-policy, sec-csp, perf-immutable-assets * * Every value is a typed argument. This module reads no environment variables. */ export type SecurityHeaderOptions = { /** Extra origins allowed to serve scripts. Keep this list empty if you can. */ scriptSrc?: string[]; /** Extra origins the page may connect to (fetch, XHR, WebSocket, beacon). */ connectSrc?: string[]; /** Extra origins allowed to serve images. */ imgSrc?: string[]; /** Extra origins allowed to serve fonts. */ fontSrc?: string[]; /** Extra origins allowed as form action targets, e.g. a hosted checkout. */ formAction?: string[]; /** Extra origins allowed to be framed by this site, e.g. an embedded player. */ frameSrc?: string[]; /** * Emit `Content-Security-Policy-Report-Only` instead of the enforcing header. * Use this for one deploy when tightening an existing policy. */ reportOnly?: boolean; /** Endpoint that receives CSP violation reports. */ reportUri?: string; /** * Nonce-based script policy. Requires middleware that generates a per-request * nonce, which forces dynamic rendering. Off by default because a * static-first site cannot pay that cost. See the comment on `scriptSrcValue`. */ nonce?: string; /** `max-age` for HSTS in seconds. Default: two years. */ hstsMaxAge?: number; }; export type HeaderRule = { key: string; value: string }; const DEFAULT_HSTS_MAX_AGE = 63_072_000; // 2 years /** * Script policy. * * Next.js App Router streams the RSC payload through inline * `self.__next_f.push(...)` scripts. Allowing them requires one of: * * 1. a per-request nonce, which means reading headers in the root layout and * giving up static prerendering on every route, or * 2. per-page hashes, which change on every build and cannot be expressed in * a static header, or * 3. `'unsafe-inline'`. * * A static-first site takes option 3 and compensates elsewhere: no external * script origins, `object-src 'none'`, `base-uri 'self'`, and a locked * `form-action`. That combination still blocks the common injection paths * (remote script loading, base-tag hijacking, form exfiltration, plugin * execution). Document the tradeoff rather than pretending it away. */ function scriptSrcValue(opts: SecurityHeaderOptions): string { const sources = ["'self'", ...(opts.scriptSrc ?? [])]; if (opts.nonce) { // Nonce mode: 'strict-dynamic' lets nonced loaders pull their own chunks. return [`'nonce-${opts.nonce}'`, "'strict-dynamic'", "https:", ...sources].join(" "); } return [...sources, "'unsafe-inline'"].join(" "); } export function contentSecurityPolicy(opts: SecurityHeaderOptions = {}): string { const directives: Array<[string, string[]]> = [ ["default-src", ["'self'"]], ["script-src", [scriptSrcValue(opts)]], // Tailwind v4 with `experimental.inlineCss` emits a <style> tag per route. ["style-src", ["'self'", "'unsafe-inline'"]], ["img-src", ["'self'", "data:", "blob:", ...(opts.imgSrc ?? [])]], ["font-src", ["'self'", "data:", ...(opts.fontSrc ?? [])]], ["connect-src", ["'self'", ...(opts.connectSrc ?? [])]], ["frame-src", [...(opts.frameSrc ?? ["'none'"])]], ["frame-ancestors", ["'none'"]], ["form-action", ["'self'", ...(opts.formAction ?? [])]], ["base-uri", ["'self'"]], ["object-src", ["'none'"]], ["worker-src", ["'self'", "blob:"]], ["manifest-src", ["'self'"]], ["upgrade-insecure-requests", []], ]; if (opts.reportUri) directives.push(["report-uri", [opts.reportUri]]); return directives .map(([name, values]) => (values.length ? `${name} ${values.join(" ")}` : name)) .join("; "); } export function securityHeaders(opts: SecurityHeaderOptions = {}): HeaderRule[] { const hstsMaxAge = opts.hstsMaxAge ?? DEFAULT_HSTS_MAX_AGE; return [ { key: "Strict-Transport-Security", value: `max-age=${hstsMaxAge}; includeSubDomains; preload`, }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "X-Frame-Options", value: "DENY" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "Permissions-Policy", value: [ "camera=()", "microphone=()", "geolocation=()", "payment=()", "usb=()", "magnetometer=()", "gyroscope=()", "accelerometer=()", "interest-cohort=()", ].join(", "), }, { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, { key: "Cross-Origin-Resource-Policy", value: "same-origin" }, { key: "X-DNS-Prefetch-Control", value: "on" }, { key: opts.reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy", value: contentSecurityPolicy(opts), }, ]; } export const IMMUTABLE_ASSET_EXTENSIONS = [ "ico", "png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "woff", "woff2", "ttf", "otf", ] as const; /** * Cache rules. Static assets get a year and `immutable`, which is only safe * because every asset URL carries a version query string. The manifest gets a * day. Feeds get an hour with a matching `s-maxage` so the CDN and the browser * agree. */ export function cacheHeaderRules() { return [ { source: `/:path*.(${IMMUTABLE_ASSET_EXTENSIONS.join("|")})`, headers: [ { key: "Cache-Control", value: "public, max-age=31536000, immutable" }, ], }, { source: "/manifest.webmanifest", headers: [ { key: "Cache-Control", value: "public, max-age=86400, s-maxage=86400" }, ], }, { source: "/:path*(feed.xml|rss.xml)", headers: [ { key: "Cache-Control", value: "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400", }, ], }, { source: "/:path*(llms.txt|llms-full.txt)", headers: [ { key: "Cache-Control", value: "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400", }, { key: "Content-Type", value: "text/plain; charset=utf-8" }, ], }, ]; }X-Content-Type-Options nosniff2/S
Why it matters
Without it a browser may execute a file the server labelled as text, which turns a file upload into a script injection.
How to verify
curl -sI https://example.com | grep -i x-content-type-options.
Implementation snippet
The tested implementation from kit/config/headers.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Framing denied through both X-Frame-Options and frame-ancestors2/S
Why it matters
X-Frame-Options is the legacy header and frame-ancestors is the modern one. Older browsers read only the first, newer ones prefer the second.
How to verify
curl -sI https://example.com | grep -iE 'x-frame-options|frame-ancestors'.
Implementation snippet
The tested implementation from kit/config/headers.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Referrer-Policy set to strict-origin-when-cross-origin2/S
Why it matters
The default leaks full URLs, including query strings that may carry tokens, to every third party a page touches.
How to verify
curl -sI https://example.com | grep -i referrer-policy.
Implementation snippet
The tested implementation from kit/config/headers.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Permissions-Policy denying camera, microphone, geolocation and payment1/S
Why it matters
It costs one header and it removes an entire class of prompt from any third-party script or embedded frame.
How to verify
curl -sI https://example.com | grep -i permissions-policy.
Implementation snippet
The tested implementation from kit/config/headers.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.A real Content-Security-Policy, with the compromises documented3/L
Why it matters
A missing CSP is the most common gap on otherwise well-built sites. A strict script-src on a statically prerendered app requires a nonce, which forces dynamic rendering, so the honest answer is a locked-down policy everywhere else plus a written note about the tradeoff.
How to verify
curl -sI https://example.com | grep -i content-security-policyThen confirm object-src none, base-uri self, frame-ancestors none and a constrained form-action.
Implementation snippet
The tested implementation from kit/config/headers.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Secret comparison is timing-safe, with a deliberate delay on failure3/S
Why it matters
A plain equality check returns as soon as two bytes differ, which leaks the length of the matching prefix to anyone who can measure response time across many requests.
How to verify
Send 100 requests with a wrong token and 100 with a correct-prefix token and confirm the response time distributions overlap.
Implementation snippet
The tested implementation from kit/seo/indexnow.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Every write endpoint runs the same validation pipeline in the same order4/M
Why it matters
Origin, content type, length, rate limit, schema parse, sanitise. Hand-rolling a subset per route is how one endpoint ends up without a rate limit.
How to verify
Post to any write endpoint with a foreign Origin header and confirm a 403 before any parsing happens.
Every interactive element is keyboard reachable with a visible focus ring3/M
Why it matters
A checklist app that cannot be operated from the keyboard is unusable for part of the audience and fails an automated audit immediately.
How to verify
Tab through the whole page without touching the mouse. Every actionable element must receive focus, in visual order, with a visible ring.
Semantic landmarks and a skip link as the first focusable element2/S
Why it matters
Without a skip link a keyboard user tabs through 30 navigation items on every page. Landmarks are how a screen reader user jumps straight to the content.
How to verify
Load the page, press Tab once, and confirm the first focused element is a visible skip link pointing at the main landmark.
16px minimum font size on inputs and 44px minimum tap targets3/S
Why it matters
iOS zooms the viewport when a focused input has a font-size below 16px. It is the single most common mobile defect on otherwise good sites.
How to verify
Open the site on a real iPhone, tap a form field, and confirm the page does not zoom. Then confirm every button is at least 44px tall.
No horizontal page scroll at 375px, with code blocks scrolling inside their own container3/M
Why it matters
A code-heavy site is the most likely kind to break this, and a horizontally scrolling body makes a page feel broken even when nothing is.
How to verify
At 375px width run `document.documentElement.scrollWidth > document.documentElement.clientWidth` in the console and confirm it is false on every template.
Terms, privacy, licence and refund pages exist, are linked sitewide, and name real processors2/M
Why it matters
A privacy policy that does not name the processors it uses is not a privacy policy. These pages are also a trust signal on a site asking for money.
How to verify
Confirm each page is reachable from the footer of every page and that the privacy policy names every third party that receives data.
The consent banner genuinely prevents analytics from loading before consent2/M
Why it matters
A banner that appears after the tracker has already initialised is decorative. It claims compliance it does not deliver, which is worse than having no banner.
How to verify
Open the site in a fresh profile, check the network tab before answering the banner, and confirm zero requests to the analytics host.
Measurement
How to tell whether any of this worked. Without segmentation you cannot distinguish an indexation problem from a ranking problem.
Search Console queries are segmented by page tier, not read in aggregate4/M
Why it matters
Sitewide impressions hide everything. A programmatic tier can be collapsing while the total looks flat because the blog is growing.
How to verify
In Search Console, filter Pages by a URL pattern per tier and confirm you can produce impressions and clicks for each tier separately.
You can distinguish an indexation problem from a ranking problem4/S
Why it matters
They look identical in a traffic chart and have opposite fixes. Impressions near zero with pages indexed is a ranking problem; pages not indexed is a crawl or quality problem.
How to verify
For a struggling tier, compare submitted versus indexed in the Pages report against impressions in the Performance report. Zero impressions with full indexation is a ranking problem.
Revenue events fire server-side from the webhook, never from a client success page4/M
Why it matters
A success page is skipped on redirect failure, blocked by extensions and replayed on refresh. Revenue reporting built on it is wrong in both directions.
How to verify
Complete a test purchase, close the tab before the success page loads, and confirm the purchase event still recorded exactly once.
Event names come from one const object, imported everywhere3/S
Why it matters
A typo in an event name is a silently broken funnel nobody notices for a month, and renaming an event after launch orphans every chart built on it.
How to verify
grep -rn 'capture("' app components | grep -v EVENTSThen confirm no raw string event names.
Web vitals are captured with element attribution, not just a number2/M
Why it matters
Knowing LCP moved from 1.4s to 2.6s is not actionable. Knowing it moved and now points at a specific image is.
How to verify
Confirm the LCP event payload includes the element tag and identifier, then check a real event in the analytics tool.
Analytics is reverse-proxied through your own origin3/S
Why it matters
Ad blockers block the analytics vendor's domain, not yours. Without the proxy an unknown and non-random share of the funnel is invisible.
How to verify
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/ingest/static/array.jsThen confirm 200.
Sequencing
What to build first when you have two weeks instead of six months, ordered by expected return.
Ship the foundation group before anything else5/S
Why it matters
Canonical, host and status-code correctness are prerequisites. Structured data on a site with duplicate hosts is decoration on a broken frame.
How to verify
Confirm every check in the foundation group passes before starting any other group.
With two weeks, do foundation, metadata, one schema graph and robots. Nothing else.4/S
Why it matters
These four carry most of the available return and none of them depend on content volume. Programmatic tiers and AEO are worth more later and worth nothing on a broken base.
How to verify
Sort the checklist by impact divided by effort and confirm your first two weeks of work matches the top of that list.
Do not build a programmatic tier before you have data worth templating4/S
Why it matters
The tier's value comes entirely from the per-entity data. Building the template first produces a doorway page generator waiting for content.
How to verify
Before building the tier, write one page by hand. If it is thin, the tier will be thin 1,689 times.
Instrument before optimising, so improvements are attributable3/S
Why it matters
Shipping ten changes in a week without measurement means you learn nothing about which one worked, and you will repeat the useless ones.
How to verify
Confirm the analytics taxonomy and Search Console tier segmentation are in place before the first optimisation sprint.
A scheduled review of the two chapters that age fastest2/S
Why it matters
Crawler user agents and AI engine behaviour change faster than anything else on this list. A quarterly pass is the difference between a reference and a fossil.
How to verify
Confirm a calendar entry exists and that the last review date on those chapters is under 90 days old.
Failure modes
The specific ways each mechanism backfires. Every item here is something that looked like an improvement on the day it shipped.
Watch for over-indexation of a thin tier dragging down the whole domain5/M
Why it matters
Quality signals are evaluated at the site level as well as the page level. A thousand thin pages can suppress the fifty good ones.
How to verify
In Search Console, compare indexed count against the count of pages with at least one impression in 90 days. A large gap in one tier is the signal.
Watch for FAQ schema being treated as spam4/S
Why it matters
Marking up questions that are not visible, or stuffing a FAQ onto every page regardless of relevance, is a documented manual action trigger.
How to verify
Check Search Console Manual Actions, and confirm every FAQ question in schema appears verbatim in the rendered HTML.
Watch for canonical chains and loops5/M
Why it matters
A canonical pointing at a URL that canonicalises elsewhere creates a chain. Google follows one hop and gives up on the second, so the page falls out of the index entirely.
How to verify
For each template, fetch the canonical target and confirm its own canonical points at itself: `curl -s $(curl -s URL | grep -o 'canonical" href="[^"]*' | cut -d'"' -f3) | grep canonical`.
Watch for the IndexNow key leaking into a public repository3/S
Why it matters
Anyone holding the key can submit arbitrary URLs on your host, which is a way to have your domain associated with content you never published.
How to verify
git log -p --all -S "$INDEXNOW_KEY" | headThen confirm the key has never been committed.
Implementation snippet
The tested implementation from kit/seo/indexnow.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.Watch for a programmatic tier being classified as doorway pages5/M
Why it matters
The classification is site-level and it is not appealable by argument, only by deletion. This is the failure that ends a programmatic strategy rather than slowing it.
How to verify
Apply the strip-the-entity-name test quarterly, not just at launch. Content decays as source data goes stale.
Watch for a tightened CSP silently breaking analytics or checkout3/M
Why it matters
A CSP violation fails silently in production for most users. The funnel keeps rendering and stops reporting, which looks like a traffic drop rather than a bug.
How to verify
Deploy the policy in report-only mode for one release, collect violation reports, and confirm zero reports involve your own scripts before enforcing it.
Implementation snippet
The tested implementation from kit/config/headers.ts, plus the test that fails if it regresses.
Unlock with the Code Kit$14930 day refund, no questions.
Questions
How many checks does the SEO for Solos checklist contain?
The SEO for Solos checklist contains 112 implementation checks across 12 groups, from canonical URLs through to llms.txt and answer engine citation guidance.
Is the checklist free?
The SEO for Solos checklist is free and needs no account. Every check shows what it is, why it matters and a command to verify it. 26 of the 112 implementation snippets are unlocked; the rest are part of the paid tiers.
Does my progress save?
Progress on the SEO for Solos checklist saves to your browser without an account, and syncs across devices once you sign in. Nothing is lost when you sign in: local progress is merged rather than replaced.
What does the impact score mean?
Each check in the SEO for Solos checklist carries an impact estimate from 1 to 5 and an effort size of small, medium or large. Sorting by return on effort puts the highest-value work first, which is what the sequencing chapter is built on.
Can I export the checklist?
The SEO for Solos checklist exports to a GitHub-flavoured Markdown task list, and to one paste-ready GitHub issue per outstanding check. Both are free and need no account.