SEO for Solos

Metadata

Metadata covers 11 of the SEO for Soloschecklist's implementation checks. Titles, descriptions, canonicals and social cards. The cheapest wins on the list and the most commonly half-finished.

11
Checks in group
3
Free snippets
3.2
Average impact
of 5
2
Position
of 12 groups

0 of 11

Saved in this browser

11 checks shown.

Metadata

Titles, descriptions, canonicals and social cards. The cheapest wins on the list and the most commonly half-finished.

  • A title template with a keyword-led default, not a brand-led one4/S

    Why it matters

    The first 60 characters of a title carry nearly all its weight. Leading with the brand on every page spends that budget on a word nobody searched for.

    How to verify

    curl -s https://example.com/ | grep -o '<title>[^<]*</title>'

    Then confirm the head term appears before the brand name.

    Implementation

    kit/seo/constants.ts
    /**
     * Code Kit: SEO primitives.
     * Implements checks: meta-canonical-absolute, meta-canonical-normalised,
     *                    meta-open-graph, meta-twitter-card, meta-title-template
     *
     * The only module in the kit permitted to read process.env, and it falls back
     * gracefully when the variable is absent.
     */
    
    /** Resolve the site origin once. Callers should import this, never re-derive it. */
    export function resolveSiteUrl(fallback: string): string {
      const fromEnv =
        process.env.NEXT_PUBLIC_SITE_URL ??
        (process.env.VERCEL_PROJECT_PRODUCTION_URL
          ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
          : undefined);
      return stripTrailingSlashes(fromEnv && fromEnv.length > 0 ? fromEnv : fallback);
    }
    
    function stripTrailingSlashes(value: string): string {
      return value.replace(/\/+$/, "");
    }
    
    /**
     * Normalise a path into an absolute canonical URL.
     *
     * Handles the cases that actually break in production:
     *   ""            -> origin
     *   "/"           -> origin
     *   "/a/"         -> origin + "/a"      (trailing slash stripped)
     *   "//a"         -> origin + "/a"      (protocol-relative path collapsed)
     *   "a/b"         -> origin + "/a/b"    (missing leading slash added)
     *   "/a?b=1#c"    -> query and fragment preserved, path normalised
     *   "https://x/y" -> returned unchanged (already absolute)
     */
    export function canonicalUrl(path: string | undefined, siteUrl: string): string {
      const origin = stripTrailingSlashes(siteUrl);
      if (!path) return origin;
    
      if (/^https?:\/\//i.test(path)) return stripTrailingSlashes(path) || path;
    
      // Split off query and fragment before touching slashes.
      const queryIndex = path.search(/[?#]/);
      const rawPath = queryIndex === -1 ? path : path.slice(0, queryIndex);
      const suffix = queryIndex === -1 ? "" : path.slice(queryIndex);
    
      let normalised = rawPath.replace(/\/{2,}/g, "/");
      if (!normalised.startsWith("/")) normalised = `/${normalised}`;
      normalised = normalised.replace(/\/+$/, "");
    
      if (normalised === "" || normalised === "/") {
        return suffix ? `${origin}/${suffix}` : origin;
      }
      return `${origin}${normalised}${suffix}`;
    }
    
    export type OpenGraphImage = {
      url: string;
      width?: number;
      height?: number;
      alt: string;
      type?: string;
    };
    
    export type OpenGraphOptions = {
      title: string;
      description: string;
      url: string;
      siteName: string;
      locale?: string;
      type?: "website" | "article" | "profile" | "book";
      images?: OpenGraphImage[];
      publishedTime?: string;
      modifiedTime?: string;
      authors?: string[];
      section?: string;
      tags?: string[];
    };
    
    const DEFAULT_OG_IMAGE_WIDTH = 1200;
    const DEFAULT_OG_IMAGE_HEIGHT = 630;
    
    /** Open Graph object with the defaults that matter, omitting anything undefined. */
    export function openGraph(opts: OpenGraphOptions) {
      const images = (opts.images ?? []).map((image) => ({
        url: image.url,
        width: image.width ?? DEFAULT_OG_IMAGE_WIDTH,
        height: image.height ?? DEFAULT_OG_IMAGE_HEIGHT,
        alt: image.alt,
        type: image.type ?? inferImageType(image.url),
      }));
    
      return compact({
        title: opts.title,
        description: opts.description,
        url: opts.url,
        siteName: opts.siteName,
        locale: opts.locale ?? "en_US",
        type: opts.type ?? "website",
        images: images.length > 0 ? images : undefined,
        publishedTime: opts.publishedTime,
        modifiedTime: opts.modifiedTime,
        authors: opts.authors?.length ? opts.authors : undefined,
        section: opts.section,
        tags: opts.tags?.length ? opts.tags : undefined,
      });
    }
    
    export type TwitterCardOptions = {
      title: string;
      description: string;
      images?: string[];
      creator?: string;
      site?: string;
    };
    
    export function twitterCard(opts: TwitterCardOptions) {
      return compact({
        card: "summary_large_image" as const,
        title: opts.title,
        description: opts.description,
        images: opts.images?.length ? opts.images : undefined,
        creator: opts.creator,
        site: opts.site,
      });
    }
    
    function inferImageType(url: string): string | undefined {
      const match = /\.(png|jpe?g|webp|avif|gif|svg)(?:\?|$)/i.exec(url);
      if (!match) return undefined;
      const ext = match[1]?.toLowerCase();
      switch (ext) {
        case "png":
          return "image/png";
        case "jpg":
        case "jpeg":
          return "image/jpeg";
        case "webp":
          return "image/webp";
        case "avif":
          return "image/avif";
        case "gif":
          return "image/gif";
        case "svg":
          return "image/svg+xml";
        default:
          return undefined;
      }
    }
    
    /**
     * Drop undefined, null and empty-string values recursively.
     *
     * Structured data with `"author": ""` is worse than structured data without an
     * author: it asserts a fact that is empty. Every factory in the kit runs its
     * output through this.
     */
    export function compact<T>(value: T): T {
      if (Array.isArray(value)) {
        const cleaned = value
          .map((item) => compact(item))
          .filter((item) => item !== undefined && item !== null && item !== "");
        return cleaned as unknown as T;
      }
      if (value && typeof value === "object" && !(value instanceof Date)) {
        const entries = Object.entries(value as Record<string, unknown>)
          .map(([key, val]) => [key, compact(val)] as const)
          .filter(([, val]) => {
            if (val === undefined || val === null || val === "") return false;
            if (Array.isArray(val) && val.length === 0) return false;
            if (typeof val === "object" && Object.keys(val as object).length === 0) return false;
            return true;
          });
        return Object.fromEntries(entries) as T;
      }
      return value;
    }
    meta-title-templateImpact 4/5SmallFreeRead the chapter
  • 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.
    meta-unique-titlesImpact 4/5MediumFreeRead the chapter
  • 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.
    meta-description-with-numberImpact 3/5SmallRead the chapter
  • 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;
    }
    meta-canonical-absoluteImpact 5/5SmallFreeRead the chapter
  • 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.
    meta-canonical-normalisedImpact 4/5SmallRead the chapter
  • 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.
    meta-open-graphImpact 3/5SmallRead the chapter
  • 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.
    meta-twitter-cardImpact 2/5SmallRead the chapter
  • 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.
    meta-robots-directivesImpact 3/5SmallRead the chapter
  • 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 hreflang

    Then 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.
    meta-hreflang-xdefaultImpact 2/5SmallRead the chapter
  • 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.
    meta-icons-versionedImpact 2/5MediumRead the chapter
  • 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.
    meta-noindex-private-routesImpact 3/5SmallRead the chapter