Foundation
Foundation covers 5 of the SEO for Soloschecklist's implementation checks. The base layer every other check assumes. Get these wrong and nothing above them can work.
0 of 5
Saved in this browser
5 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, ""); }