How to Automate Schema Markup in Your Codebase (2026)

Hand-written JSON-LD rots the moment a price or a date changes. Here is how to generate schema from the data your pages already render, validate it in CI, and stop maintaining it by hand.

Alec Lindsay
August 27, 2026
7 min read
On this page

TL;DR — Stop writing JSON-LD by hand. Derive it from the same data the page already renders, emit it from one typed helper per schema type, and validate it in CI so a broken object fails the build instead of failing silently in Search Console. The three patterns below cover most sites, and the last section shows how to generate the whole layer with a coding agent.

Why hand-written schema rots

Most sites add structured data once, during a technical SEO push, and never touch it again. Then the page changes and the markup does not.

The failure is quiet. A Product block still says "price": "49.00" after the price moved to $59. An Article still carries datePublished from the day someone pasted the snippet, and dateModified never updates because nothing updates it. A BreadcrumbList points at a URL structure you migrated away from last quarter. Google keeps reading all of it, and none of it throws an error anywhere you would look.

The root cause is duplication. The price exists in your database and again in a hand-written JSON blob. The publish date exists in frontmatter and again in a script tag. Two copies of one fact drift, and the copy nobody renders on screen is the one that drifts unnoticed.

Automation here means one thing: the schema reads from the same source the visible page reads from, so it cannot disagree.

The three patterns

1. Derive schema from the data you already have

The rule is that no fact appears twice. If the page component receives an article object, the schema builder receives the same object.

// lib/schema/article.ts — one typed builder per schema type
export function buildArticleSchema(article: Article, siteUrl: string) {
  return {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: article.title,
    description: article.description,
    datePublished: article.publishedAt,
    dateModified: article.updatedAt ?? article.publishedAt,
    author: { '@type': 'Person', name: article.author },
    mainEntityOfPage: `${siteUrl}/blog/${article.slug}`,
  };
}
// app/blog/[slug]/page.tsx
const article = await getArticle(slug);

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(buildArticleSchema(article, SITE_URL)) }}
/>

dateModified now tracks the real edit date because it reads the same field the page shows. Change the title and the schema changes with it. There is no second copy to forget.

This is the pattern the site you are reading uses. The blog route builds Article and BreadcrumbList from the parsed frontmatter, so a post cannot ship with stale markup.

2. Generate the FAQ layer from the content itself

Q&A markup is the highest-value schema for AI answer engines, and the most tedious to maintain by hand, because the questions live in the prose.

Parse them out instead. If your FAQ sections follow a convention (an h2 that says "FAQ", questions as h3), one function turns that structure into FAQPage JSON-LD:

export function buildFaqPageSchema(html: string) {
  const section = html.match(
    /<h2\b[^>]*>\s*(?:frequently asked questions|faqs?)\s*<\/h2>([\s\S]*?)(?=<h2\b|$)/i,
  );
  if (!section) return null;

  const items = [...section[1].matchAll(/<h3\b[^>]*>([\s\S]*?)<\/h3>([\s\S]*?)(?=<h3\b|$)/gi)]
    .map(([, q, a]) => ({
      '@type': 'Question',
      name: toText(q),
      acceptedAnswer: { '@type': 'Answer', text: toText(a) },
    }));

  return items.length >= 2
    ? { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: items }
    : null;
}

Two details matter more than they look. Returning null below two questions keeps you from marking up a section that is not really an FAQ. And parsing the rendered HTML rather than the source markdown means the same function works whether the content came from files or a database.

Writers keep writing FAQs in prose. The markup appears on its own.

3. Validate in CI so a broken object fails the build

Generating schema is not enough if nothing checks it. Add a test that renders each schema type and asserts the shape:

import { describe, it, expect } from 'vitest';
import { buildArticleSchema } from '../lib/schema/article';

describe('Article schema', () => {
  it('always carries the fields Google requires', () => {
    const schema = buildArticleSchema(fixtureArticle, 'https://example.com');
    expect(schema.headline).toBeTruthy();
    expect(schema.headline.length).toBeLessThanOrEqual(110); // Google truncates past this
    expect(Date.parse(schema.datePublished)).not.toBeNaN();
    expect(Date.parse(schema.dateModified)).not.toBeNaN();
  });

  it('falls back to publishedAt when a post was never edited', () => {
    const schema = buildArticleSchema({ ...fixtureArticle, updatedAt: null }, 'https://example.com');
    expect(schema.dateModified).toBe(fixtureArticle.publishedAt);
  });
});

For the full validity check, run the generated JSON through Google's Rich Results Test or the Schema Markup Validator before shipping a new type. Once a type is proven valid, the unit test is what keeps it that way.

Which types are worth automating

Not every schema type earns its maintenance cost. In rough order of return:

Type Put it on Why it pays
Article Every blog post Cheapest to derive, feeds article rich results
BreadcrumbList Every nested page Derived entirely from the route, near-zero cost
FAQPage Posts with a real FAQ The block AI answer engines quote most
Product Commerce pages Price and availability rich results
SoftwareApplication SaaS product pages Category and offer clarity for AI answers
Organization Homepage only Establishes the entity behind everything else
HowTo Step-by-step guides Reduced in Google results, still parsed by AI engines

Two rules keep you out of trouble. Only mark up what is visible on the page, because schema describing content a user cannot see is a spam signal. And put Organization on the homepage alone rather than site-wide, so there is one canonical entity definition.

Doing this with a coding agent

A coding agent can build the whole layer, because every step is a repo change: read the data shape, write the builder, wire it into the route, add the test.

The prompt that works is specific about the source of truth:

Read the Article type in lib/types.ts and the blog route. Add a typed buildArticleSchema helper that derives every field from the article object, emit it from the route, and write vitest cases asserting the required fields and the dateModified fallback. Do not hardcode any value that already exists in the data.

That last sentence is the one that matters. Without it you get a schema helper with a literal date in it, which is the problem you started with.

SEOAgent does this as part of its audit: it crawls the live site, finds which pages are missing markup and which have markup that disagrees with the page, and writes the builders and tests into your repo as a diff you approve. It runs as a free Skill inside Claude Code, Cursor, and Codex, and it works on your own model rather than metered credits. Because the output is a commit, the schema layer is reviewed the same way as the rest of your code.

Whatever generates it, the check is the same: change a price or a publish date, rebuild, and read the JSON-LD. If it moved with the page, the automation is real. If it did not, you have two copies of a fact and one of them is already wrong.

FAQ

Can schema markup be generated automatically?

Yes. Derive it from the data the page already renders rather than writing JSON by hand. A typed builder function per schema type, called from the route, keeps markup and page content in sync by construction.

It helps with extraction. AI answer engines parse structured data to identify entities, questions, and answers on a page. FAQPage and Article are the types most often reflected in generated answers, which is why they are the first two worth automating.

What is the best way to add JSON-LD in Next.js?

Render a <script type="application/ld+json"> in the page component with JSON.stringify of an object built from the page's own data. Doing it in the component rather than in metadata keeps the schema next to the data it describes, and it works in both the app and pages routers.

How do I stop schema markup from going stale?

Remove the duplicate copy of the fact. If datePublished is hardcoded in a snippet, it will rot; if it reads article.publishedAt, it cannot. Then add a unit test per schema type so a change that breaks the shape fails the build instead of failing silently in Search Console.

Should every page have schema markup?

No. Mark up what a page actually is, and only content visible to the user. A generic WebPage block on every route adds no rich-result eligibility and dilutes the markup that does.

Tags:Technical SEOSchemaDevelopers

Put SEO on autopilot in your own editor

SEOAgent runs as a free skill inside Claude Code, Cursor, and Codex — on the model you already pay for. Audit, plan, and write SEO content right in your repo, with every change reviewed before it ships. No second AI subscription.

Get SEOAgent free