Skip to content
Developer implementing JSON-LD schema markup

Schema Markup Implementation: A Developer's Guide

· by Digitelia · 4 min read

Schema markup is one of those topics where the SEO advice is well-understood but the actual implementation guidance for developers is scattered. SEOs say “add Article schema, Product schema, FAQ schema.” Developers ask “where, how, with what shape, and how do I keep it correct across 10,000 pages?”

This guide is the implementation perspective — patterns for adding JSON-LD schema correctly in modern frameworks, shared utilities for reuse, testing approaches, and CMS integration. Written for the developer who’s going to actually ship the code.

Code editor with schema markup

Why JSON-LD (and not microdata or RDFa)

Three schema formats exist:

  • JSON-LD: separate <script type="application/ld+json"> block. Google’s preferred format.
  • Microdata: inline HTML attributes (itemscope, itemprop).
  • RDFa: similar to microdata, different syntax.

Use JSON-LD. Reasons:

  • Google explicitly prefers it
  • Separates schema from HTML rendering — easier to maintain
  • Works with dynamic data (you build the JSON server-side or client-side)
  • Doesn’t clutter HTML markup
  • Easier to test and validate

Microdata and RDFa work, but JSON-LD is the modern standard.

The implementation pattern

For every page type that benefits from schema:

  1. Define a schema generation function for that page type
  2. Call it during page render, passing in the page’s data
  3. Output the result as <script type="application/ld+json"> in the page head or body
  4. Test in Rich Results Test before going live

The function is the unit of reuse. Shared utility module → schema-per-page-type functions → render in template.

Astro implementation

Astro’s component model fits JSON-LD naturally. Build a <SchemaScript> component:

---
// src/components/SchemaScript.astro
interface Props {
  schema: Record<string, unknown> | Record<string, unknown>[];
}
const { schema } = Astro.props;
const arr = Array.isArray(schema) ? schema : [schema];
---
{arr.map((s) => (
  <script type="application/ld+json" set:html={JSON.stringify(s)} />
))}

Then per-page-type schema generators:

// src/utils/schema.ts
import type { CollectionEntry } from 'astro:content';

export function articleSchema(post: CollectionEntry<'posts'>, siteUrl: string) {
  return {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.data.title,
    description: post.data.description,
    image: post.data.hero?.image ? new URL(post.data.hero.image, siteUrl).href : undefined,
    datePublished: post.data.publishedAt.toISOString(),
    dateModified: (post.data.updatedAt ?? post.data.publishedAt).toISOString(),
    author: {
      '@type': 'Person',
      name: post.data.author,
      url: `${siteUrl}/about/${post.data.author.toLowerCase().replace(/\s+/g, '-')}/`,
    },
    publisher: {
      '@type': 'Organization',
      name: 'YourBrand',
      logo: { '@type': 'ImageObject', url: `${siteUrl}/logo.png` },
    },
    mainEntityOfPage: post.data.seo?.canonical ?? `${siteUrl}/blog/${post.slug}/`,
  };
}

export function breadcrumbSchema(crumbs: Array<{ name: string; url: string }>) {
  return {
    '@context': 'https://schema.org',
    '@type': 'BreadcrumbList',
    itemListElement: crumbs.map((c, i) => ({
      '@type': 'ListItem',
      position: i + 1,
      name: c.name,
      item: c.url,
    })),
  };
}

In your blog post page:

---
import SchemaScript from '@components/SchemaScript.astro';
import { articleSchema, breadcrumbSchema } from '@utils/schema';
const { post } = Astro.props;
const siteUrl = 'https://yourdomain.com';
const schemas = [
  articleSchema(post, siteUrl),
  breadcrumbSchema([
    { name: 'Home', url: `${siteUrl}/` },
    { name: 'Blog', url: `${siteUrl}/blog/` },
    { name: post.data.title, url: `${siteUrl}/blog/${post.slug}/` },
  ]),
];
---
<head>
  <SchemaScript schema={schemas} />
</head>

Clean, reusable, testable.

Next.js implementation

Similar pattern with App Router:

// app/lib/schema.ts
export function articleSchema(post: Post) {
  return {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    // ... same structure as Astro example
  };
}

In your page:

// app/blog/[slug]/page.tsx
export default function BlogPost({ post }: { post: Post }) {
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema(post)) }}
      />
      <article>{/* ... */}</article>
    </>
  );
}

Pages Router uses similar approach, just placed inside <Head> component.

Code review for schema implementation

WordPress implementation

For WordPress, three paths:

Path 1: Plugin. Yoast SEO, RankMath, and Schema Pro all generate schema automatically. Easiest but limited customization.

Path 2: Custom theme functions. Add to functions.php:

add_action('wp_head', 'output_article_schema');

function output_article_schema() {
  if (!is_single()) return;

  $post_id = get_the_ID();
  $schema = [
    '@context' => 'https://schema.org',
    '@type' => 'BlogPosting',
    'headline' => get_the_title(),
    'description' => get_the_excerpt(),
    'datePublished' => get_the_date('c'),
    'dateModified' => get_the_modified_date('c'),
    'author' => [
      '@type' => 'Person',
      'name' => get_the_author(),
      'url' => get_author_posts_url(get_the_author_meta('ID')),
    ],
    'publisher' => [
      '@type' => 'Organization',
      'name' => get_bloginfo('name'),
      'logo' => ['@type' => 'ImageObject', 'url' => get_site_logo_url()],
    ],
  ];

  if (has_post_thumbnail()) {
    $schema['image'] = get_the_post_thumbnail_url(null, 'large');
  }

  echo '<script type="application/ld+json">' . wp_json_encode($schema) . '</script>';
}

Path 3: Custom plugin. Reusable across sites. Wraps schema generation in admin-configurable settings.

Headless CMS + decoupled frontend

For Contentful, Sanity, Strapi + Next.js/Astro/Nuxt frontend:

  • Schema generation lives in your frontend code (Astro/Next), not the CMS
  • CMS provides the data (post title, author, dates)
  • Frontend builds the schema at render time

The CMS doesn’t need schema-awareness. Keep schema as a frontend concern.

Per-page-type schemas you typically need

For most sites:

Blog post pages: Article (or BlogPosting) + BreadcrumbList

Product pages (e-commerce): Product (with offers, aggregateRating) + BreadcrumbList

Category / collection pages: BreadcrumbList + optional CollectionPage or ItemList

Homepage: Organization + WebSite (with SearchAction)

About page: Organization + Person (for founders/leadership)

Local business pages: LocalBusiness (with address, geo, openingHours)

FAQ pages: FAQPage

How-to / tutorial pages: HowTo

Video pages: VideoObject

Event pages: Event

Each type has required and recommended fields. The schema generation function should enforce required fields and gracefully handle optional ones.

Shared utility module structure

A typical schema utility module:

// src/utils/schema.ts

export const SITE_URL = 'https://yourdomain.com';
export const ORG = {
  name: 'YourBrand',
  logo: `${SITE_URL}/logo.png`,
  sameAs: [
    'https://twitter.com/yourbrand',
    'https://linkedin.com/company/yourbrand',
  ],
};

export function organizationSchema() { /* ... */ }
export function websiteSchema() { /* ... */ }
export function articleSchema(post) { /* ... */ }
export function productSchema(product) { /* ... */ }
export function breadcrumbSchema(crumbs) { /* ... */ }
export function faqSchema(qas) { /* ... */ }
export function howToSchema(steps) { /* ... */ }
export function videoSchema(video) { /* ... */ }
export function localBusinessSchema(business) { /* ... */ }

// Helper: combine multiple schemas
export function multiSchema(...schemas) {
  return schemas.filter(Boolean);
}

Single source of truth. Each generator function takes the relevant data and returns the JSON object. The component that renders them is dumb.

Testing approaches

1. Google Rich Results Test

Manual testing of individual URLs. Best for spot-checking after major changes.

URL: search.google.com/test/rich-results

2. Schema.org Validator

Validates against the broader Schema.org spec.

URL: validator.schema.org

3. Automated tests in your build pipeline

For larger sites, write tests that validate schema generation:

// tests/schema.test.ts
import { describe, it, expect } from 'vitest';
import { articleSchema } from '@/utils/schema';

describe('articleSchema', () => {
  it('generates valid BlogPosting schema', () => {
    const post = {
      data: { title: 'Test', publishedAt: new Date('2026-01-01'), author: 'Author' },
      slug: 'test',
    };
    const schema = articleSchema(post as any, 'https://example.com');
    expect(schema['@type']).toBe('BlogPosting');
    expect(schema.headline).toBe('Test');
    expect(schema.datePublished).toBe('2026-01-01T00:00:00.000Z');
  });
});

4. Search Console monitoring

After deploy, monitor Enhancements reports in Search Console for any schema errors. They appear within 24-72 hours of crawl.

Common implementation mistakes

1. Schema doesn’t match visible content. Required by Google’s policy. FAQ schema must mirror actual FAQ content on the page.

2. Missing required fields. Each type has specific required fields. Missing them = no rich result eligibility.

3. Wrong URL format. Absolute URLs required. Relative URLs (/uploads/img.jpg) fail.

4. Forgetting publisher logo on Article schema. Required for Article-type rich results.

5. Dynamic content updating after schema generation. Schema generated server-side at render but content updates client-side — mismatch.

6. Duplicate schemas of the same type. Multiple Article scripts on the same page confuses Google.

7. Schema for testing/development pages leaking to production. Filter by page status (published, indexed) before adding schema.

8. Not handling missing data. Schema with null or undefined fields breaks validation. Gracefully omit missing fields.

Performance considerations

Schema markup adds bytes to HTML. For large sites, this matters:

  • Average Article schema: 500-1,500 bytes
  • Average Product schema: 800-2,000 bytes
  • Multiple schemas can add 2-5KB to HTML

Mitigations:

  • Generate at build time when possible (static generation)
  • Cache aggressively (CDN-cacheable when source data is stable)
  • Don’t add schemas that don’t produce rich results — pure overhead

For Astro and Next.js with static generation, the cost is negligible. For server-side rendered pages, schema generation should be cached per page.

A 30-day schema implementation rollout

Days 1-5: Audit and plan.

  • Identify page types and their corresponding schema types
  • Build shared utility module with type-specific generators

Days 6-15: Implement and test.

  • Add schemas to top 3-5 page types (blog post, product page, homepage)
  • Run Rich Results Test on examples of each
  • Fix issues, deploy to staging

Days 16-22: Production rollout.

  • Deploy to production
  • Submit URLs to Search Console for re-indexing
  • Monitor Enhancements reports

Days 23-30: Add remaining types.

  • Add specialized schemas (FAQ, HowTo, VideoObject) where content supports
  • Validate each before adding broadly
  • Build automated tests for regression prevention

By day 30, all major page types have schema and tests guard against regression.

Frequently asked questions

Should I use Google’s structured data testing tool or Schema.org validator? Use both. Rich Results Test tells you what triggers Google rich results. Schema.org validator tells you what’s correct per spec. Both matter.

Will schema slow my page down? Marginally. JSON-LD is text and small. Performance impact is negligible compared to images, scripts, fonts.

Do I need schema if Yoast or RankMath already generates it (WordPress)? If you use those plugins, basic schema is generated. Custom additions still useful for category-specific schemas not covered.

Can client-side JavaScript-generated schema be parsed by Google? Google does execute JavaScript when rendering, but server-rendered/SSR schema is more reliable. Avoid pure client-side schema generation.

How often should I update schema? When page content changes meaningfully. Article schema’s dateModified should reflect actual content updates. Other schemas typically static once configured.


Schema markup is one of those engineering investments where the upfront work compounds over years. A 30-day implementation rollout produces rich result eligibility, AI search citation improvements, and structured data that downstream tools (analytics, SEO platforms, social scrapers) all leverage. The pattern is straightforward; the discipline is in keeping it correct as content scales.

Tagged

#schema#json-ld#structured-data#developer#implementation#all-audiences