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

Whichever framework renders the page, the graph should be assembled from content fields in one place and serialised once.

One graph builder, one serialisation point Content fields feeding a single graph builder whose output is serialised into the server-rendered head. title, author dates, images breadcrumb trail graph builder one function, typed server response one ld+json script

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

Structured data problems fall into three tiers, and only the first is visible without a validator.

Three tiers of structured data defect Absent markup, invalid markup, and markup that is valid but ineligible or misleading, ordered by how easily each is noticed. tier how you notice consequence absent grep the served HTML no rich results invalid a validator block discarded valid but wrong comparing to the page manual action risk The least visible tier carries the largest consequence
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

Structured data as a projection of the content model

The most maintainable way to think about structured data in a composable stack is as a projection: a pure function from a content entry to a schema graph. Written that way, it has no dependency on the rendering framework, it can be unit-tested without a browser, and it produces the same output whether it runs at build time, at request time, or in a validation script.

The alternative — assembling the graph incrementally as components render — is how most implementations begin, and it is the source of nearly every silent failure in this area. A property disappears because a component was refactored; a type is emitted twice because two components each contributed one; a field is populated from the DOM rather than from the data. None of these produces an error, and all of them are invisible in the rendered page.

Treating it as a projection also clarifies scope. The graph should describe what the entry is, using only fields the entry actually has. Adding types the content does not support in order to qualify for a rich result produces incomplete graphs at best and markup that misdescribes the page at worst, and the second of those is a policy problem rather than a technical one.

Keeping the graph and the page in agreement

The requirement that structured data describe visible content is easy to satisfy at launch and easy to break afterwards. A page redesign moves the FAQ into a tab; an editor removes a section; a component becomes conditional. In each case the graph keeps describing content that is no longer there, and nothing in the build notices.

The durable defence is to derive both from the same source. If the FAQ entries in the graph come from the same array the template renders, they cannot disagree. Where that is impractical, an automated comparison between the graph and the rendered text is the next best thing — imperfect, but far better than a periodic manual review that nobody schedules twice.

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