Abdelrahman

JSON-LD schema templates by site type

Most JSON-LD advice online is either a wall of schema.org spec pages or a single toy Person example that doesn't tell you how real entities connect. This is a copy-ready reference: 7 site categories, each with the schema types worth using, a full TypeScript template with the nodes actually linked via @id, where it goes in an App Router project, and the honest gotchas — including where a rich result is realistic and where it isn't.

If your site's structured data is already broken and you want to find out exactly where, that's what next-seo-doctor is for — it crawls your built site and flags dangling @id references, unescaped script breakouts, and invalid JSON-LD blocks directly. This page is what you paste in once the checks are clean.

Every category returns a graph — an array of @id-linked nodes — not isolated nodes with no relationship to each other. Render any of them with the same component:

tsx
// components/json-ld.tsx — shared by every template below
export function JsonLd({ data }: { data: object }) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify(data).replace(/</g, "\\u003c"),
      }}
    />
  );
}

That .replace(/</g, "\\u003c") isn't optional — without it, any title, description, or FAQ answer pulled from user input or a CMS can close your <script> tag early and inject markup.

Blog / content site

Which schemas to use. A site-wide Person (or Organization) and WebSite, then a BlogPosting per post whose author links back to the Person via @id, plus a BreadcrumbList on each post.

tsx
const SITE_URL = "https://YOUR_SITE_URL";
const personId = `${SITE_URL}/#person`;
const siteId = `${SITE_URL}/#website`;
 
export function blogPostSchema(post: {
  slug: string;
  title: string;
  description: string;
  datePublished: string;
  dateModified: string;
}) {
  return {
    "@context": "https://schema.org",
    "@graph": [
      {
        "@type": "Person",
        "@id": personId,
        name: "YOUR_NAME",
        url: SITE_URL,
      },
      {
        "@type": "WebSite",
        "@id": siteId,
        url: SITE_URL,
        name: "YOUR_SITE_NAME",
        publisher: { "@id": personId },
      },
      {
        "@type": "BlogPosting",
        "@id": `${SITE_URL}/blog/${post.slug}/#post`,
        headline: post.title,
        description: post.description,
        datePublished: post.datePublished,
        dateModified: post.dateModified,
        author: { "@id": personId },
        isPartOf: { "@id": siteId },
        url: `${SITE_URL}/blog/${post.slug}`,
      },
      {
        "@type": "BreadcrumbList",
        itemListElement: [
          { "@type": "ListItem", position: 1, name: "Home", item: SITE_URL },
          { "@type": "ListItem", position: 2, name: "Blog", item: `${SITE_URL}/blog` },
          { "@type": "ListItem", position: 3, name: post.title, item: `${SITE_URL}/blog/${post.slug}` },
        ],
      },
    ],
  };
}

Where it goes. Person + WebSite render once, site-wide (root layout or homepage). BlogPosting + BreadcrumbList render per post, in the post page.

When and when not.

  • Don't invent a datePublished/dateModified split if you don't track edits — use the same date for both rather than a fake modified date.
  • author should be an @id reference to the same Person node everywhere, not a repeated inline object — that's what makes it a graph instead of confetti.

Rich-result reality check. BlogPosting can earn article rich results (byline, date) in some surfaces; the bigger win is Google reliably understanding who wrote what across your whole site, which compounds over many posts.


Built alongside next-seo-doctor — run it against your built site to check whether your own graph actually holds together. More on the reasoning behind all this in Technical SEO in Next.js.