Dev.to WebDev 🛠 Dev 👁 1 📖 4 min read

Our structured data is two script tags, and the rule is that no node may claim more than the page shows

Every public page on Nakodo ships exactly two blocks of structured data. One comes from the marketing layout and describes the things that exist once: the organisation and the website. One comes from the page and describ

Every public page on Nakodo ships exactly two blocks of structured data. One comes from the marketing layout and describes the things that exist once: the organisation and the website. One comes from the page and describes what that page actually is.

You can read both on any page. View source on a vetting guide and the page block is BreadcrumbList, Article, FAQPage and HowTo. Do the same on the pricing page and it is BreadcrumbList and a SoftwareApplication with three Offer nodes.

The rule behind all of it is one sentence, which sits as a comment at the top of the builder file: every node describes something the page shows a reader. Markup that claims more than the page does is a manual action waiting to happen, not a ranking trick.

Nodes that exist once, referenced by @id

The naive version of this puts a full Organization object inside every Article, every WebPage and every Offer. Then the description drifts in four places and crawlers get four slightly different companies.

Instead there are three stable ids:

export const ORGANIZATION_ID = organizationIdFor(SELF_APP);
export const WEBSITE_ID = `${SITE_URL}/#website`;
export const SOFTWARE_ID = `${SITE_URL}/#software`;

The layout emits the organisation and the website once per page, and everything else points at them:

export function articleLd(a: { /* … */ }): Ld {
  return {
    "@type": a.post ? "BlogPosting" : "Article",
    "@id": `${absoluteUrl(a.path)}#article`,
    mainEntityOfPage: absoluteUrl(a.path),
    headline: a.headline,
    datePublished: a.published,
    dateModified: a.modified,
    author: { "@id": ORGANIZATION_ID },
    publisher: { "@id": ORGANIZATION_ID },
    isPartOf: { "@id": WEBSITE_ID },
    // …
  };
}

author: { "@id": … } is the entire author block. The organisation it points at is in the other script tag on the same page, and because both are @graph blocks on one document, a consumer resolves the reference without another fetch.

The @id for the article itself is the page URL plus #article, not the page URL. The page URL belongs to the WebPage. Two nodes sharing an @id is how you tell a crawler that an article and a web page are the same thing, which they are not.

The @graph wrapper, and one replace that matters

export function JsonLd({ data }: { data: Record<string, unknown> | Record<string, unknown>[] }) {
  const doc = Array.isArray(data)
    ? { "@context": "https://schema.org", "@graph": data }
    : { "@context": "https://schema.org", ...data };
  return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(doc).replace(/</g, "\\u003c") }} />;
}

Pass an array, get a @graph; pass an object, get a single node. The replace is the only line in there with teeth. JSON.stringify escapes quotes and backslashes but it does not escape <, and the content of a script element is not parsed as HTML entities, so a page title containing </script> would end the script tag early and dump the rest of the JSON into the document as markup. Escaping < to < costs nothing and closes that off permanently. JSON-LD parsers read < as <.

The conditions on each node

Two of the builders carry a condition in their doc comment, because they are the two that tempt you into lying.

softwareLd includes the plans as Offer nodes:

offers: PLAN_ORDER.map((id) => ({
  "@type": "Offer",
  name: PLANS[id].name,
  price: PLANS[id].price,
  priceCurrency: CURRENCIES[BASE_CURRENCY].code,
  description: PLANS[id].tagline,
  url: absoluteUrl("/pricing"),
})),

It is only emitted on pages that show the prices, because Google asks for a marked-up price to be visible on the page. And the numbers are read from src/lib/plans.ts, which is the same module the plan cards render from, so there is no second copy of the price to forget to update.

faqLd is only called with the same questions rendered visibly, from the same array:

faqLd(page.faqs.map((f) => ({ q: f.q, a: plain(f.a) })))

plain() strips the inline link syntax out of the answer, since the markup wants text and the page wants anchors.

howToLd has the strictest condition, and it is the one that is actually enforced by a test rather than a comment. A content page may name one of its own sections as its how-to:

...(howTo && steps ? [howToLd({ name: howTo.heading, steps: steps.items.map(/* … */) })] : [])

and the content test refuses to build if that name points at a section with no steps in it:

if (page.howTo) {
  const s = page.sections.find((x) => x.id === page.howTo);
  assert.ok(s?.blocks.some((b) => b.type === "steps"), "howTo must name a section with a steps block");
}

So a page cannot claim to be a numbered procedure because the prose happens to be sequential. The steps in the markup are the steps on the page, in the same order, with the same text, or the markup does not exist. The calculators under /tools go the other way and pass an extra node in, so a page you can type numbers into carries a WebApplication alongside its article rather than pretending to be prose.

What this buys

Nothing glamorous. Breadcrumbs render in search results, the FAQ blocks are eligible where Google still shows them, and the organisation is consistent across all 76 pages and the four sibling apps that name the same parent company.

The real payoff is that a node can never outlive the thing it describes. Delete a steps block and the HowTo disappears with it. Change a plan price and the Offer changes in the same commit, because it is the same constant. The failure mode of hand-written structured data is markup that is slowly, invisibly, confidently wrong, and the only fix is to never write it by hand.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.