Connected schema nodes as a glowing graph on dark background

    Automate JSON-LD Schema for SPAs: Structured Data Without a Backend

    14. April 20267 min read
    Till Freitag

    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 Freitag

    Why SPAs Have No Structured Data

    Single Page Applications have a double SEO problem:

    1. Rendering: The HTML body is initially empty (solved by Playwright SSG)
    2. 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 item field 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:

    1. Google Rich Results Test: search.google.com/test/rich-results
    2. Schema.org Validator: validator.schema.org
    3. 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:

    1. Lovable → Generates the app
    2. Playwright SSGRenders static HTML
    3. JSON-LD Schema → Structured data (this article)
    4. 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.

    TeilenLinkedInWhatsAppE-Mail

    Related Articles

    Google Search Console dashboard with performance graphs and coverage reports
    April 14, 20265 min

    Google Search Console for Vibe Coding Projects: Setup, Debugging & Indexing

    Your Lovable app is live on Vercel – but Google isn't indexing anything? How to set up Search Console, debug crawling is

    Read more
    Rocket made of code elements launching through search result pages with Lighthouse Score 100
    April 14, 20266 min

    Vibe Coding & SEO: Why AI-Generated Apps Stay Invisible – And How We Fix It

    Lovable, Bolt, v0 – Vibe Coding tools produce SPAs that Google can't see. Our playbook makes them SEO-ready: SSG, Schema

    Read more
    Social media preview cards with OG-Image meta tags floating in dark space
    April 14, 20264 min

    OG-Image Best Practices for SPAs: Making Your Vibe Coding Projects Shareable

    When you share a link from your Lovable app, LinkedIn shows an empty box. Why SPAs don't deliver social previews – and h

    Read more
    Prerendering pipeline visualization: SPA, Playwright, Schema.org and edge deploy
    April 29, 20263 min

    Prerendering: How to Turn a React SPA Into a Google-Friendly Site

    React SPAs are invisible to crawlers. Prerendering fixes that – without Next.js, without an SSR server. How our Playwrig

    Read more
    Pipeline diagram with three stations: Lovable, GitHub, Vercel
    April 14, 20264 min

    Lovable → GitHub → Vercel: The Complete Deployment Flow for SEO-Ready Apps

    Lovable generates the app, GitHub versions the code, Vercel delivers with < 50ms TTFB. This guide shows the complete flo

    Read more
    Headless browser rendering SPA pages as static HTML for search engines
    April 14, 20266 min

    Playwright SSG Tutorial: How to Make Lovable Apps Visible to Google

    SPAs are invisible to search engines. With Playwright, you can render any Lovable app into static HTML – automated, on e

    Read more
    Classic drag-and-drop website builder blocks compared to an AI-native website stack
    June 13, 20263 min

    Website Builders vs. AI-Native Stack: Which Path Wins in 2026?

    Wix, Squarespace, Jimdo & co. vs. an AI-native stack with Lovable, Cloud and edge hosting – when does each really pay of

    Read more
    Cloudflare and Vercel head-to-head – two edge platforms, two philosophies
    June 4, 20265 min

    Cloudflare vs. Vercel – Which One Should You Use When?

    Vercel or Cloudflare? Both host your modern web app at the edge – but they pursue fundamentally different strategies. We

    Read more
    Lovable app with structured HTML, visible to Google, ChatGPT and Perplexity
    May 13, 20267 min

    Lovable SEO/AEO: Every App Discoverable by Google and ChatGPT From Day 1

    Lovable ships server-side rendering, pre-rendering for existing apps, Semrush directly in the builder chat, and an on-de

    Read more