~/TechPurAI
~/tutorials/nextjs-from-scratch/json-ld-structured-data
intermediate·part 10 of 22·3 min read

Structured data: JSON-LD for search and AI answer engines

Updated Aug 16, 2026JavaScript · Next.js

The Metadata API from part 9 covers what a browser tab and a social preview read. JSON-LD is a separate, additional layer — structured data embedded directly in the page that search engines and AI answer engines (ChatGPT, Perplexity, Google's AI Overviews) parse specifically to understand what a page is, not just what it says.

Adding Article JSON-LD to a post

tsx
// app/posts/[slug]/page.tsx
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = getAllPosts().find((p) => p.slug === slug);
  if (!post) notFound();

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: post.title,
    description: post.excerpt,
    datePublished: post.date,
  };

  return (
    <article>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

dangerouslySetInnerHTML is the right tool here specifically because JSON-LD has to render as raw, literal <script> content — React's normal JSX escaping would otherwise mangle the JSON. JSON.stringify(jsonLd) on a plain object built directly from post's real data is what keeps this from drifting out of sync with the page's actual content — nothing here is a separately maintained document.

Adding breadcrumbs

tsx
const breadcrumbJsonLd = {
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  itemListElement: [
    { "@type": "ListItem", position: 1, name: "Home", item: "https://devnotes.example.com" },
    { "@type": "ListItem", position: 2, name: "Posts", item: "https://devnotes.example.com/posts" },
    { "@type": "ListItem", position: 3, name: post.title, item: `https://devnotes.example.com/posts/${slug}` },
  ],
};

Render this the same way, in a second script tag — BreadcrumbList is what powers the breadcrumb trail Google sometimes shows directly in a search result instead of the raw URL, and it's a second, independent signal about the page's place in the site's structure.

Why this specifically matters for AI answer engines

A search engine (and an AI system generating an answer from web content) can technically infer a page's topic from its prose — but JSON-LD hands it over explicitly, structured, and unambiguous: this is an Article, here's its exact headline, here's when it was published. That's a meaningfully easier thing for a system extracting a direct answer to lift correctly than parsing it back out of paragraph text. Sites that mark this up explicitly are simply giving these systems less inference work to get right.

Sharing this across every post

tsx
// lib/json-ld.ts
export function articleJsonLd(post: { title: string; excerpt: string; date: string }) {
  return {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: post.title,
    description: post.excerpt,
    datePublished: post.date,
  };
}

A small helper like this — built once, imported into every page that needs Article JSON-LD — is what keeps the shape consistent as the blog grows past one post template, the same reasoning as any other shared utility in the project.

Common mistake

Hand-writing the JSON-LD as a static object with hardcoded values instead of deriving it from the same post data the page itself renders. The moment a post's title changes, a hardcoded JSON-LD block silently goes stale — deriving it from the real object, as done above, makes that impossible.

Next: images — nothing rendered so far has included one, and next/image handles more than a plain <img> tag ever does automatically.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous9. The Metadata API: static and per-page SEOnext →11. next/image: automatic image optimization