
Automate JSON-LD Schema for SPAs: Structured Data Without a Backend
TL;DR: „JSON-LD Schema makes your SPA visible to Google's Rich Results. With React components, you can automatically generate Organization, BlogPosting, and BreadcrumbList on every page – no backend, no CMS needed."
— Till FreitagWhy SPAs Have No Structured Data
Single Page Applications have a double SEO problem:
- Rendering: The HTML body is initially empty (solved by Playwright SSG)
- Structured Data: There's no backend to automatically inject JSON-LD into the HTML head
Without structured data, you miss out on:
- Rich Snippets in Google search results (star ratings, FAQ dropdowns, breadcrumbs)
- Knowledge Graph entries for your business
- Event listings in Google Search
- Article carousels for blog content
Context: This article is part of our Vibe Coding SEO Playbook. There you'll find the full overview.
JSON-LD Basics
JSON-LD (JavaScript Object Notation for Linked Data) is Google's preferred format for structured data. It's placed as <script type="application/ld+json"> in the HTML and describes page content in a machine-readable way.
Example: Organization Schema
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Till Freitag",
"url": "https://till-freitag.com",
"logo": "https://till-freitag.com/logo.png",
"sameAs": [
"https://linkedin.com/company/tillfreitag",
"https://github.com/tillfreitag"
]
}This schema tells Google: "Here's a company called Till Freitag, reachable at this URL."
The React Component: SEOHead
Instead of manually copying JSON-LD into every page, we build a reusable React component that generates the schema automatically:
// components/seo/SEOHead.tsx
import { Helmet } from 'react-helmet-async';
interface SEOHeadProps {
title: string;
description: string;
canonicalUrl: string;
jsonLd?: Record<string, unknown>[];
ogImage?: string;
ogType?: string;
}
export default function SEOHead({
title,
description,
canonicalUrl,
jsonLd = [],
ogImage,
ogType = 'website',
}: SEOHeadProps) {
return (
<Helmet>
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalUrl} />
{/* Open Graph */}
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content={ogType} />
<meta property="og:url" content={canonicalUrl} />
{ogImage && <meta property="og:image" content={ogImage} />}
{/* JSON-LD Schemas */}
{jsonLd.map((schema, i) => (
<script key={i} type="application/ld+json">
{JSON.stringify(schema)}
</script>
))}
</Helmet>
);
}Important: The component accepts an array of schemas. This way you can combine multiple schema types per page.
Schema Types for Typical Pages
1. BlogPosting (for Articles)
function generateBlogPostingSchema(post: BlogPost) {
return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
url: `https://your-domain.com/blog/${post.slug}`,
datePublished: post.date,
dateModified: post.modified || post.date,
wordCount: post.content.split(/\s+/).length,
author: {
'@type': 'Person',
name: post.author.name,
url: `https://your-domain.com/blog/author/${post.author.slug}`,
},
publisher: {
'@type': 'Organization',
name: 'Your Company',
logo: {
'@type': 'ImageObject',
url: 'https://your-domain.com/logo.png',
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://your-domain.com/blog/${post.slug}`,
},
};
}2. BreadcrumbList (for Navigation)
function generateBreadcrumbSchema(
items: { name: string; path: string }[]
) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
item: {
'@type': 'WebPage',
'@id': `https://your-domain.com${item.path}`,
name: item.name,
},
})),
};
}⚠️ Common mistake: The
itemfield must be a structured object with@id– not just a string. Google's Rich Results Test will report errors otherwise.
3. ProfessionalService (for Service Pages)
function generateServiceSchema(service: Service) {
return {
'@context': 'https://schema.org',
'@type': 'ProfessionalService',
name: service.name,
description: service.description,
url: `https://your-domain.com${service.path}`,
provider: {
'@type': 'Organization',
name: 'Your Company',
},
areaServed: [
{ '@type': 'Country', name: 'Germany' },
{ '@type': 'Country', name: 'Austria' },
{ '@type': 'Country', name: 'Switzerland' },
],
serviceType: service.type,
};
}4. FAQPage (for FAQ Sections)
function generateFAQSchema(
faqs: { question: string; answer: string }[]
) {
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map(faq => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer,
},
})),
};
}Automatic Schema Generation per Route
The most elegant approach: define schema rules per route type and let the component automatically choose the right schema:
// lib/schema-config.ts
type SchemaRule = {
pattern: RegExp;
generate: (params: RouteParams) => Record<string, unknown>[];
};
const schemaRules: SchemaRule[] = [
{
pattern: /^\/blog\/[^/]+$/,
generate: (params) => [
generateBlogPostingSchema(params.post),
generateBreadcrumbSchema([
{ name: 'Home', path: '/' },
{ name: 'Blog', path: '/blog' },
{ name: params.post.title, path: params.path },
]),
],
},
{
pattern: /^\/services\//,
generate: (params) => [
generateServiceSchema(params.service),
generateBreadcrumbSchema([
{ name: 'Home', path: '/' },
{ name: 'Services', path: '/services' },
{ name: params.service.name, path: params.path },
]),
],
},
{
pattern: /^\/$/, // Homepage
generate: () => [
generateOrganizationSchema(),
generateWebSiteSchema(),
],
},
];Common Schema Mistakes
1. Missing item in BreadcrumbList
❌ Wrong:
{
"@type": "ListItem",
"position": 1,
"name": "Blog",
"item": "/blog"
}✅ Correct:
{
"@type": "ListItem",
"position": 1,
"item": {
"@type": "WebPage",
"@id": "https://your-domain.com/blog",
"name": "Blog"
}
}2. Missing offers in Event Schemas
Even for free events, Google expects an offers field:
{
"@type": "Event",
"offers": {
"@type": "Offer",
"availability": "https://schema.org/SoldOut",
"price": "0",
"priceCurrency": "EUR"
}
}3. Relative URLs Instead of Absolute URLs
❌ "url": "/blog/my-article"
✅ "url": "https://your-domain.com/blog/my-article"
Google always requires absolute URLs in JSON-LD.
Validation
After implementation:
- Google Rich Results Test: search.google.com/test/rich-results
- Schema.org Validator: validator.schema.org
- Browser DevTools:
document.querySelectorAll('script[type="application/ld+json"]')→ Check the output
Pro tip: Build an automated test that checks all routes for valid JSON-LD:
// tests/schema.test.ts
import { test, expect } from '@playwright/test';
test('all pages have valid JSON-LD', async ({ page }) => {
const routes = ['/', '/blog', '/services/vibe-coding-seo'];
for (const route of routes) {
await page.goto(`http://localhost:4173${route}`);
const schemas = await page.$eval(
'script[type="application/ld+json"]',
scripts => scripts.map(s => JSON.parse(s.textContent || '{}'))
);
expect(schemas.length).toBeGreaterThan(0);
schemas.forEach(schema => {
expect(schema['@context']).toBe('https://schema.org');
expect(schema['@type']).toBeTruthy();
});
}
});Conclusion: Schema Makes Your SPA Smart
JSON-LD Schema is the second pillar after Playwright SSG for complete SPA SEO. Together, they ensure Google can not only see your content but also understand it.
The complete Vibe Coding SEO stack:
- Lovable → Generates the app
- Playwright SSG → Renders static HTML
- JSON-LD Schema → Structured data (this article)
- Vercel Edge → Fast delivery
Sounds complex? We'll implement it for you →
In our SEO audit, we check your schema, rendering, and Core Web Vitals – and deliver a concrete implementation plan.
Related Articles
- 📸 OG-Image Best Practices for SPAs – Social previews are the most visible form of structured data. Here's how to get them right.
- 📊 Google Search Console for Vibe Coding Projects – Check if your schema markup is recognized by Google – with URL Inspection and the Rich Results Test.







