SEO for Solos

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.

5
Checks in group
3
Free snippets
3.4
Average impact
of 5
1
Position
of 12 groups

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

    Then 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;
    }
    found-single-site-urlImpact 3/5SmallFreeRead the chapter
  • 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.
    found-canonical-hostImpact 4/5SmallFreeRead the chapter
  • 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-security

    Then 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.
    found-https-everywhereImpact 3/5SmallRead the chapter
  • 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 -1

    Then 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.
    found-trailing-slash-policyImpact 3/5SmallRead the chapter
  • 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-exist

    Then 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, "");
    }
    found-404-returns-404Impact 4/5SmallFreeRead the chapter