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
curland 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.
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
- Injecting JSON-LD at the Render Layer in Headless Stacks β a typed serializer, server-side head injection, and a single collector to prevent duplicate schema across nested components
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
- Composable CMS Architecture Basics β model typed content fields that map cleanly onto schema.org types
- ISR vs SSG vs CSR Routing β choose a render mode that places JSON-LD in the first HTML response
- Canonical URL Enforcement β produce the absolute URL that schema
urland@idvalues must reuse - Headless Architecture & Rendering Strategy Fundamentals β the parent reference for rendering and indexation decisions in decoupled stacks