SEO for Solos

Structured data

Structured data covers 20 of the SEO for Soloschecklist's implementation checks. JSON-LD that describes the page to a machine, composed into one graph with stable identifiers rather than scattered across a dozen script tags.

20
Checks in group
3
Free snippets
2.6
Average impact
of 5
3
Position
of 12 groups

0 of 20

Saved in this browser

20 checks shown.

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 };
    schema-single-scriptImpact 4/5MediumFreeRead the chapter
  • 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.
    schema-graph-anchorsImpact 4/5MediumRead the chapter
  • 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.
    schema-no-empty-valuesImpact 3/5SmallRead the chapter
  • 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.
    schema-organizationImpact 3/5SmallRead the chapter
  • 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.
    schema-websiteImpact 2/5SmallRead the chapter
  • 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),
      };
    }
    schema-breadcrumbImpact 3/5SmallFreeRead the chapter
  • 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.
    schema-articleImpact 3/5MediumRead the chapter
  • 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 };
    schema-faqpageImpact 3/5SmallFreeRead the chapter
  • 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.
    content-faq-visibleImpact 3/5SmallRead the chapter
  • 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.
    schema-howtoImpact 2/5MediumRead the chapter
  • 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.
    schema-productImpact 3/5MediumRead the chapter
  • 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.
    schema-offerImpact 2/5SmallRead the chapter
  • 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.
    schema-softwareapplicationImpact 2/5SmallRead the chapter
  • 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.
    schema-serviceImpact 1/5SmallRead the chapter
  • 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.
    schema-definedtermImpact 2/5MediumRead the chapter
  • 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.
    schema-speakableImpact 2/5SmallRead the chapter
  • 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.
    schema-profilepageImpact 2/5SmallRead the chapter
  • 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.
    schema-itemlistImpact 2/5SmallRead the chapter
  • 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.
    schema-no-duplicatesImpact 3/5SmallRead the chapter