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.
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 };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.