Structured Data for Headless Stacks

In a headless stack the HTML that a crawler parses is assembled far from where content lives β€” fields sit in a CMS, mapping logic sits in a serializer, and the final markup is produced by a framework at build or request time. Structured data has to survive that whole journey intact, which means the JSON-LD must be generated from typed CMS data and injected server-side into the document head, not stitched together by client-side JavaScript that a crawler may never run.

Prerequisites

Before wiring structured data into your frontend, confirm these are in place:

  • Next.js 14+ / SvelteKit 2+ / Nuxt 3.x β€” each exposes a server-side metadata API capable of emitting a raw <script> tag in the head
  • A stable content model in your composable CMS architecture where each content type has predictable, typed fields
  • A schema.org mapping table that assigns every routable content type to exactly one primary schema.org type
  • Server-side or build-time rendering for pages that need rich results β€” client-only rendering defers the script tag past first parse; review your ISR, SSG, and CSR routing choices per template
  • curl and a schema validator (a Rich Results checker or a JSON-LD linter) available in CI

If the same absolute URL is not already produced by your canonical URL enforcement layer, the url and @id values in your JSON-LD will drift from the served address and Google may distrust the markup.

How Structured Data Flows Through a Headless Stack

The diagram shows how typed CMS fields become a validated rich result. Each stage owns one transformation, which keeps schema logic out of view components.

Structured data injection pipeline A five-stage pipeline showing how typed CMS content model fields are mapped to schema.org, serialized to JSON-LD, injected into the document head server-side, and validated into a rich result. CMS Content Model Schema Mapper to schema.org JSON-LD Serializer Head Injection SSR / build Rich Result validated one type in pure function one graph out

Step-by-Step Implementation Workflow

Step 1 β€” Map content types to schema.org types

Create an explicit table that assigns each routable content type to exactly one primary schema.org type and records which CMS field feeds which schema property. This mapping is the contract the rest of the pipeline depends on.

// lib/schema/map.ts β€” the single source of truth for type mapping
export const SCHEMA_MAP = {
  article: { type: 'Article', title: 'headline', body: 'articleBody' },
  product: { type: 'Product', title: 'name', body: 'description' },
  faq:     { type: 'FAQPage', title: 'name', body: 'text' },
} as const;

export type ContentType = keyof typeof SCHEMA_MAP;

Validation: assert that every content type returned by your CMS API appears as a key in SCHEMA_MAP. A missing key means a template will render with no structured data and should fail the build, not ship silently.

Step 2 β€” Build a typed JSON-LD serializer

Write one pure function per content type that reads CMS data and returns a plain object. Keep it free of framework imports so it runs identically in a build script, a server component, and a test.

// lib/schema/article.ts
import type { CmsArticle } from '@/lib/cms';

export function articleSchema(entry: CmsArticle, url: string) {
  return {
    '@context': 'https://schema.org',
    '@type': 'Article',
    '@id': `${url}#article`,
    headline: entry.title,
    description: entry.excerpt,
    datePublished: entry.publishedAt,
    dateModified: entry.updatedAt,
    author: { '@type': 'Organization', name: entry.authorName },
    mainEntityOfPage: url,
  };
}

Validation: unit-test the serializer with a fixture entry and assert the returned object parses as valid JSON and contains every required property for the target type.

Step 3 β€” Inject the JSON-LD server-side in the head

Emit the serialized object through the framework metadata API so the script tag is present in the first HTML response. See the Framework-Specific Code Examples section for the exact API per stack.

Step 4 β€” Deduplicate schema across nested components

Collect every fragment into a single per-page collector and emit one combined graph. This prevents a nested breadcrumb component and a page component from each printing their own Organization or BreadcrumbList block.

// lib/schema/collector.ts
export class SchemaCollector {
  private nodes = new Map<string, object>();
  add(node: { '@id': string } & Record<string, unknown>) {
    this.nodes.set(node['@id'], node); // last write per @id wins β€” no duplicates
  }
  toGraph() {
    return { '@context': 'https://schema.org', '@graph': [...this.nodes.values()] };
  }
}

Validation: render a page that mounts the breadcrumb component twice and confirm the output contains exactly one BreadcrumbList node.

Step 5 β€” Validate with the Rich Results test and CI

Run a schema linter against the rendered HTML on every deploy and spot-check representative templates in the Rich Results test. See the Validation Protocol section for the exact commands.

Framework-Specific Code Examples

Next.js App Router

The App Router lets you emit a raw script tag from a server component, so the JSON-LD is part of the SSR/build output rather than a client effect.

// app/blog/[slug]/page.tsx
import { articleSchema } from '@/lib/schema/article';
import { getCmsEntry } from '@/lib/cms';
import { resolveCanonical } from '@/lib/canonical';

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const entry = await getCmsEntry(slug);
  const url = resolveCanonical(slug, entry.canonical_override);
  const schema = articleSchema(entry, url);
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
      />
      <article>{/* ... */}</article>
    </>
  );
}

SEO impact: Because the component is a server component, the script is serialized into the HTML at build or request time. A crawler sees the structured data on first parse without executing any JavaScript.

Validation: curl -s https://example.com/blog/my-post | grep -o 'application/ld+json' must return one match per intended block, not zero and not many.

SvelteKit

Serialize in a server load function and render the tag in <svelte:head> so it is written into the prerendered or SSR HTML.

// src/routes/blog/[slug]/+page.server.ts
import type { PageServerLoad } from './$types';
import { articleSchema } from '$lib/schema/article';
import { getCmsEntry } from '$lib/cms';

export const load: PageServerLoad = async ({ params, url }) => {
  const entry = await getCmsEntry(params.slug);
  return { schema: articleSchema(entry, url.href) };
};
<!-- src/routes/blog/[slug]/+page.svelte -->
<script lang="ts">
  export let data;
</script>

<svelte:head>
  {@html `<script type="application/ld+json">${JSON.stringify(data.schema)}</script>`}
</svelte:head>

SEO impact: +page.server.ts runs at build time for prerendered routes and per request in SSR mode, so the schema lands in the static HTML file rather than being injected during hydration.

Validation: Set export const prerender = true and inspect the emitted .html file to confirm the JSON-LD block is present in the static output.

Nuxt 3

useHead accepts a script array with type: 'application/ld+json'. Called during SSR it writes the tag into the initial document.

<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
import { articleSchema } from '~/lib/schema/article';
const route = useRoute();
const { data: entry } = await useFetch(`/api/cms/${route.params.slug}`);
const url = useRequestURL();

useHead({
  script: [
    {
      type: 'application/ld+json',
      innerHTML: JSON.stringify(articleSchema(entry.value!, url.href)),
    },
  ],
});
</script>

SEO impact: Because useHead runs during the server render pass, Nitro writes the script into the HTML response. The structured data is not deferred to a client-side effect.

Validation: Run the Nuxt server, then curl the route and grep for application/ld+json to confirm the block is in the server-rendered HTML.

HTTP Headers & CDN Directives Reference

Header Required value Rationale
Content-Type text/html; charset=utf-8 JSON-LD embedded in HTML is only parsed when the document is served as HTML with a correct charset
Cache-Control public, max-age=3600, stale-while-revalidate=86400 Caches the rendered HTML β€” including the inline schema β€” so cached responses still carry structured data
X-Robots-Tag index, follow Confirms the edge is not blocking indexation on pages whose rich results you want surfaced
Vary Accept-Encoding Prevents a compressed variant without the schema from being served to a client expecting uncompressed HTML
Link <https://example.com/path>; rel="canonical" Keeps the canonical signal aligned with the url value inside the JSON-LD

Validation Protocol

Confirm the schema is in the server HTML

# The JSON-LD must exist before any JavaScript runs
curl -s https://example.com/blog/my-post \
  | grep -A0 'application/ld+json'

Expected: at least one application/ld+json script tag in the raw response. If it is absent, the schema is being injected client-side and must move to the server render path.

Lint the extracted JSON-LD in CI

# Extract and parse every JSON-LD block; non-zero exit fails the job
node scripts/extract-jsonld.js https://staging.example.com/blog/my-post \
  | jq -e 'has("@context") and has("@type")'

A parse error or a missing @context fails the pipeline before the change is promoted.

Assert schema presence in Lighthouse CI

// lighthouserc.js
module.exports = {
  assert: {
    assertions: {
      'structured-data': ['warn', { minScore: 1 }],
      'is-crawlable': ['error', { minScore: 1 }],
    },
  },
};

Troubleshooting

Symptom Root cause Fix
Rich Results test reports β€œno items detected” Schema injected client-side and the tester fetched the raw HTML Move serialization to a server component / load / useHead server pass
Two BreadcrumbList blocks in the output A nested component and the page both print their own schema Route all fragments through a single collector and emit one graph
url/@id values point at a staging domain Schema built from the request host instead of the resolved site URL Reuse the same absolute URL produced by canonical URL enforcement
Validation warns about missing required properties Serializer emitted an optional-only object for a type with required fields Add required-field assertions to the serializer unit test
Schema present in dev, absent in production The template rendered client-side in production due to a dynamic API call Audit render mode per your ISR, SSG, and CSR routing configuration
Escaped HTML entities inside JSON-LD strings Double-encoding when interpolating the serialized string Serialize once with JSON.stringify and inject as raw HTML, not through a text-escaping binding

Pages in This Section

Frequently Asked Questions

Should JSON-LD be injected server-side or client-side? Inject it server-side so the script is present in the first HTML response. Client-injected JSON-LD depends on JavaScript execution during rendering, which is deferred and unreliable for structured data parsing. Server-side injection through your framework’s metadata API guarantees the schema is in the initial payload regardless of hydration state.

How do I prevent duplicate schema from nested components? Use a single per-page collector that gathers fragments from every component and emits one combined graph in the head. If each component prints its own script tag you get repeated Organization and BreadcrumbList blocks, which dilute the signal and can trigger validation warnings. Keying fragments by @id in the collector means the last write per node wins.

Which schema types matter most for headless content sites? Article or BlogPosting for editorial pages, BreadcrumbList for navigation context, Product with Offer for commerce, and FAQPage or HowTo where the content genuinely matches the markup. Start with the types that map cleanly to fields in your composable CMS architecture rather than adding speculative markup you cannot keep accurate.

Does structured data change how a page is rendered or indexed? No. Structured data does not change rendering mode or force indexation; it describes content already in the HTML so search engines can present richer results. It only helps when the page is server-rendered or prerendered so the markup is visible on first parse, which is why render strategy and schema decisions are made together.


Part of: Headless Architecture & Rendering Strategy Fundamentals

Related