By Devport Team | Last updated: 2025-07-12 | 22 min read

The Definitive Guide to SEO for Headless WordPress

SEO for headless WordPress presents unique challenges and opportunities. Without WordPress's built-in SEO features handling the frontend, developers must implement technical SEO, meta tags, structured data, and performance optimizations manually. However, this also provides unprecedented control over SEO implementation.

This comprehensive guide covers everything you need to know about optimizing headless WordPress sites for search engines, from technical foundations to advanced strategies that leverage the performance benefits of modern JavaScript frameworks.

Table of Contents

  1. SEO Challenges in Headless Architecture
  2. Technical SEO Foundation
  3. Meta Tags and Open Graph
  4. Structured Data Implementation
  5. XML Sitemaps and Robots.txt
  6. Performance Optimization
  7. Monitoring and Tools

SEO Challenges in Headless Architecture

Traditional vs Headless SEO

Understanding the differences helps identify what needs manual implementation:

// Traditional WordPress SEO (handled automatically)
- URL structure via permalinks
- Meta tags through SEO plugins
- Sitemap generation
- Robots.txt management
- Schema markup
- Canonical URLs
- Social media tags

// Headless WordPress SEO (manual implementation required)
- Server-side rendering or static generation
- Meta tag injection
- Sitemap generation on frontend
- Structured data implementation
- Canonical URL management
- Social sharing optimization
- Core Web Vitals optimization

Common SEO Pitfalls

1. Client-Side Rendering Issues

// BAD: Pure client-side rendering
function App() {
    const [content, setContent] = useState(null);

    useEffect(() => {
        // Content loads after initial render
        fetchContent().then(setContent);
    }, []);

    return <div>{content}</div>;
}

// GOOD: Server-side rendering
export async function getServerSideProps({ params }) {
    const content = await fetchContent(params.slug);

    return {
        props: { content }
    };
}

2. Missing Meta Tags

// BAD: No meta tags
<html>
    <head>
        <title>My Site</title>
    </head>
</html>

// GOOD: Comprehensive meta tags
<html>
    <head>
        <title>{post.title} | My Site</title>
        <meta name="description" content={post.excerpt} />
        <link rel="canonical" href={canonicalUrl} />
        <meta property="og:title" content={post.title} />
        <!-- More meta tags -->
    </head>
</html>

3. JavaScript-Dependent Navigation

// BAD: JavaScript-only navigation
<div onClick={() => navigate('/page')}>Go to page</div>

// GOOD: Proper anchor tags
<a href="/page" onClick={handleClick}>Go to page</a>

Technical SEO Foundation

Server-Side Rendering (SSR) Setup

Next.js SSR Implementation

// pages/blog/[slug].js
import { getPostBySlug } from '@/lib/wordpress';
import Head from 'next/head';

export default function Post({ post, seo }) {
    return (
        <>
            <Head>
                <title>{seo.title}</title>
                <meta name="description" content={seo.description} />
                <link rel="canonical" href={seo.canonical} />
            </Head>
            <article>
                <h1>{post.title}</h1>
                <div dangerouslySetInnerHTML={{ __html: post.content }} />
            </article>
        </>
    );
}

export async function getServerSideProps({ params, req }) {
    const post = await getPostBySlug(params.slug);

    if (!post) {
        return { notFound: true };
    }

    const seo = {
        title: `${post.seo?.title || post.title} | ${process.env.SITE_NAME}`,
        description: post.seo?.metaDesc || post.excerpt,
        canonical: `${process.env.SITE_URL}/blog/${params.slug}`,
    };

    return {
        props: { post, seo }
    };
}

Static Site Generation (SSG)

// pages/blog/[slug].js - Static generation
export async function getStaticPaths() {
    const posts = await getAllPosts();

    return {
        paths: posts.map(post => ({
            params: { slug: post.slug }
        })),
        fallback: 'blocking', // Enable ISR
    };
}

export async function getStaticProps({ params }) {
    const post = await getPostBySlug(params.slug);

    if (!post) {
        return { notFound: true };
    }

    return {
        props: { post },
        revalidate: 3600, // Revalidate every hour
    };
}

URL Structure and Routing

// lib/seo/urls.js
export function generateCanonicalUrl(path) {
    const baseUrl = process.env.NEXT_PUBLIC_SITE_URL;
    // Remove trailing slashes and normalize
    const cleanPath = path.replace(/\/+$/, '');
    return `${baseUrl}${cleanPath}`;
}

export function generateBreadcrumbs(post) {
    return [
        { name: 'Home', url: '/' },
        { name: 'Blog', url: '/blog' },
        { name: post.categories[0]?.name, url: `/category/${post.categories[0]?.slug}` },
        { name: post.title, url: `/blog/${post.slug}` },
    ];
}

// Implement clean URLs
export function createUrlStructure() {
    return {
        post: (slug) => `/blog/${slug}`,
        category: (slug) => `/category/${slug}`,
        author: (slug) => `/author/${slug}`,
        page: (slug) => `/${slug}`,
        tag: (slug) => `/tag/${slug}`,
    };
}

Handling Redirects

// next.config.js
module.exports = {
    async redirects() {
        const redirects = await fetchRedirectsFromWordPress();

        return [
            // Permanent redirects from WordPress
            ...redirects.map(({ from, to }) => ({
                source: from,
                destination: to,
                permanent: true,
            })),
            // Remove trailing slashes
            {
                source: '/:path+/',
                destination: '/:path+',
                permanent: true,
            },
        ];
    },
};

// lib/wordpress/redirects.js
export async function fetchRedirectsFromWordPress() {
    const query = `
        query GetRedirects {
            redirects {
                nodes {
                    redirectFrom
                    redirectTo
                    redirectType
                }
            }
        }
    `;

    const data = await graphqlRequest(query);
    return data.redirects.nodes.map(redirect => ({
        from: redirect.redirectFrom,
        to: redirect.redirectTo,
        type: redirect.redirectType,
    }));
}

Meta Tags and Open Graph

Comprehensive Meta Tag Implementation

// components/SEO/MetaTags.jsx
import Head from 'next/head';

export default function MetaTags({ data }) {
    const {
        title,
        description,
        canonical,
        image,
        author,
        publishedTime,
        modifiedTime,
        tags,
        type = 'article',
        locale = 'en_US',
    } = data;

    return (
        <Head>
            {/* Basic Meta Tags */}
            <title>{title}</title>
            <meta name="description" content={description} />
            <link rel="canonical" href={canonical} />
            <meta name="author" content={author} />

            {/* Open Graph Tags */}
            <meta property="og:title" content={title} />
            <meta property="og:description" content={description} />
            <meta property="og:type" content={type} />
            <meta property="og:url" content={canonical} />
            <meta property="og:locale" content={locale} />
            <meta property="og:site_name" content={process.env.NEXT_PUBLIC_SITE_NAME} />

            {image && (
                <>
                    <meta property="og:image" content={image.url} />
                    <meta property="og:image:secure_url" content={image.url} />
                    <meta property="og:image:width" content={image.width} />
                    <meta property="og:image:height" content={image.height} />
                    <meta property="og:image:alt" content={image.alt} />
                </>
            )}

            {/* Article specific */}
            {type === 'article' && (
                <>
                    <meta property="article:published_time" content={publishedTime} />
                    <meta property="article:modified_time" content={modifiedTime} />
                    <meta property="article:author" content={author} />
                    {tags?.map(tag => (
                        <meta key={tag} property="article:tag" content={tag} />
                    ))}
                </>
            )}

            {/* Twitter Card Tags */}
            <meta name="twitter:card" content="summary_large_image" />
            <meta name="twitter:title" content={title} />
            <meta name="twitter:description" content={description} />
            <meta name="twitter:image" content={image?.url} />
            <meta name="twitter:creator" content={process.env.NEXT_PUBLIC_TWITTER_HANDLE} />

            {/* Additional SEO Tags */}
            <meta name="robots" content="index, follow, max-image-preview:large" />
            <meta name="googlebot" content="index, follow" />
            <meta name="bingbot" content="index, follow" />
        </Head>
    );
}

Dynamic Meta Tag Generation

// lib/seo/generateMeta.js
export function generateMetaData(post, siteConfig) {
    // Use Yoast SEO data if available
    const seoData = post.seo || {};

    const title = seoData.title || `${post.title} | ${siteConfig.name}`;
    const description = seoData.metaDesc || post.excerpt || siteConfig.description;

    // Generate or extract featured image
    const image = post.featuredImage?.node || siteConfig.defaultImage;

    return {
        title: truncate(title, 60), // Google typically shows 50-60 chars
        description: truncate(description, 160), // Google typically shows 155-160 chars
        canonical: `${siteConfig.url}/blog/${post.slug}`,
        image: {
            url: image.sourceUrl,
            width: image.mediaDetails?.width || 1200,
            height: image.mediaDetails?.height || 630,
            alt: image.altText || post.title,
        },
        author: post.author?.node?.name || siteConfig.author,
        publishedTime: post.date,
        modifiedTime: post.modified,
        tags: post.tags?.nodes?.map(tag => tag.name) || [],
        type: 'article',
    };
}

function truncate(str, length) {
    if (!str || str.length <= length) return str;
    return str.substring(0, length - 3) + '...';
}

Structured Data Implementation

JSON-LD Schema Markup

// components/SEO/StructuredData.jsx
export default function StructuredData({ type, data }) {
    let schema;

    switch (type) {
        case 'article':
            schema = generateArticleSchema(data);
            break;
        case 'website':
            schema = generateWebsiteSchema(data);
            break;
        case 'breadcrumb':
            schema = generateBreadcrumbSchema(data);
            break;
        case 'organization':
            schema = generateOrganizationSchema(data);
            break;
        default:
            return null;
    }

    return (
        <script
            type="application/ld+json"
            dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
        />
    );
}

function generateArticleSchema(post) {
    return {
        '@context': 'https://schema.org',
        '@type': 'Article',
        headline: post.title,
        description: post.excerpt,
        image: post.featuredImage ? [
            post.featuredImage.sourceUrl,
        ] : undefined,
        datePublished: post.date,
        dateModified: post.modified,
        author: {
            '@type': 'Person',
            name: post.author.name,
            url: post.author.url,
        },
        publisher: {
            '@type': 'Organization',
            name: process.env.NEXT_PUBLIC_SITE_NAME,
            logo: {
                '@type': 'ImageObject',
                url: `${process.env.NEXT_PUBLIC_SITE_URL}/logo.png`,
            },
        },
        mainEntityOfPage: {
            '@type': 'WebPage',
            '@id': `${process.env.NEXT_PUBLIC_SITE_URL}/blog/${post.slug}`,
        },
        wordCount: post.content.split(' ').length,
        articleBody: post.content.replace(/<[^>]+>/g, ''), // Strip HTML
    };
}

function generateBreadcrumbSchema(breadcrumbs) {
    return {
        '@context': 'https://schema.org',
        '@type': 'BreadcrumbList',
        itemListElement: breadcrumbs.map((item, index) => ({
            '@type': 'ListItem',
            position: index + 1,
            name: item.name,
            item: `${process.env.NEXT_PUBLIC_SITE_URL}${item.url}`,
        })),
    };
}

function generateWebsiteSchema(data) {
    return {
        '@context': 'https://schema.org',
        '@type': 'WebSite',
        name: data.name,
        description: data.description,
        url: data.url,
        potentialAction: {
            '@type': 'SearchAction',
            target: {
                '@type': 'EntryPoint',
                urlTemplate: `${data.url}/search?q={search_term_string}`,
            },
            'query-input': 'required name=search_term_string',
        },
    };
}

function generateOrganizationSchema(data) {
    return {
        '@context': 'https://schema.org',
        '@type': 'Organization',
        name: data.name,
        url: data.url,
        logo: data.logo,
        sameAs: [
            data.social.twitter,
            data.social.facebook,
            data.social.linkedin,
        ].filter(Boolean),
        contactPoint: {
            '@type': 'ContactPoint',
            telephone: data.phone,
            contactType: 'customer service',
            areaServed: data.areaServed,
            availableLanguage: data.languages,
        },
    };
}

Rich Snippets Implementation

// lib/seo/richSnippets.js
export function generateFAQSchema(faqs) {
    return {
        '@context': 'https://schema.org',
        '@type': 'FAQPage',
        mainEntity: faqs.map(faq => ({
            '@type': 'Question',
            name: faq.question,
            acceptedAnswer: {
                '@type': 'Answer',
                text: faq.answer,
            },
        })),
    };
}

export function generateHowToSchema(howTo) {
    return {
        '@context': 'https://schema.org',
        '@type': 'HowTo',
        name: howTo.title,
        description: howTo.description,
        image: howTo.image,
        totalTime: howTo.totalTime,
        estimatedCost: {
            '@type': 'MonetaryAmount',
            currency: howTo.currency,
            value: howTo.cost,
        },
        supply: howTo.supplies?.map(supply => ({
            '@type': 'HowToSupply',
            name: supply,
        })),
        tool: howTo.tools?.map(tool => ({
            '@type': 'HowToTool',
            name: tool,
        })),
        step: howTo.steps.map((step, index) => ({
            '@type': 'HowToStep',
            text: step.text,
            image: step.image,
            name: step.name,
            url: `${howTo.url}#step${index + 1}`,
        })),
    };
}

export function generateReviewSchema(review) {
    return {
        '@context': 'https://schema.org',
        '@type': 'Review',
        itemReviewed: {
            '@type': review.itemType,
            name: review.itemName,
        },
        reviewRating: {
            '@type': 'Rating',
            ratingValue: review.rating,
            bestRating: review.maxRating || 5,
        },
        author: {
            '@type': 'Person',
            name: review.authorName,
        },
        datePublished: review.date,
        reviewBody: review.body,
    };
}

XML Sitemaps and Robots.txt

Dynamic Sitemap Generation

// pages/sitemap.xml.js
export async function getServerSideProps({ res }) {
    const baseUrl = process.env.NEXT_PUBLIC_SITE_URL;

    // Fetch all content from WordPress
    const [posts, pages, categories] = await Promise.all([
        getAllPosts(),
        getAllPages(),
        getAllCategories(),
    ]);

    const sitemap = generateSitemap({
        baseUrl,
        posts,
        pages,
        categories,
    });

    res.setHeader('Content-Type', 'text/xml');
    res.setHeader('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate');
    res.write(sitemap);
    res.end();

    return { props: {} };
}

function generateSitemap({ baseUrl, posts, pages, categories }) {
    return `<?xml version="1.0" encoding="UTF-8"?>
    <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
            xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"
            xmlns:xhtml="http://www.w3.org/1999/xhtml"
            xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
        <url>
            <loc>${baseUrl}</loc>
            <lastmod>${new Date().toISOString()}</lastmod>
            <changefreq>daily</changefreq>
            <priority>1.0</priority>
        </url>
        ${posts.map(post => `
            <url>
                <loc>${baseUrl}/blog/${post.slug}</loc>
                <lastmod>${post.modified || post.date}</lastmod>
                <changefreq>weekly</changefreq>
                <priority>0.8</priority>
                ${post.featuredImage ? `
                    <image:image>
                        <image:loc>${post.featuredImage.sourceUrl}</image:loc>
                        <image:title>${escapeXml(post.featuredImage.altText || post.title)}</image:title>
                    </image:image>
                ` : ''}
            </url>
        `).join('')}
        ${pages.map(page => `
            <url>
                <loc>${baseUrl}/${page.slug}</loc>
                <lastmod>${page.modified || page.date}</lastmod>
                <changefreq>monthly</changefreq>
                <priority>0.7</priority>
            </url>
        `).join('')}
        ${categories.map(category => `
            <url>
                <loc>${baseUrl}/category/${category.slug}</loc>
                <changefreq>weekly</changefreq>
                <priority>0.6</priority>
            </url>
        `).join('')}
    </urlset>`;
}

function escapeXml(unsafe) {
    return unsafe.replace(/[<>&'"]/g, (c) => {
        switch (c) {
            case '<': return '&lt;';
            case '>': return '&gt;';
            case '&': return '&amp;';
            case '\'': return '&apos;';
            case '"': return '&quot;';
        }
    });
}

// Export default component (required by Next.js)
export default function Sitemap() {
    return null;
}

Sitemap Index for Large Sites

// pages/sitemap-index.xml.js
export async function getServerSideProps({ res }) {
    const baseUrl = process.env.NEXT_PUBLIC_SITE_URL;

    const sitemapIndex = `<?xml version="1.0" encoding="UTF-8"?>
    <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
        <sitemap>
            <loc>${baseUrl}/sitemap-posts.xml</loc>
            <lastmod>${new Date().toISOString()}</lastmod>
        </sitemap>
        <sitemap>
            <loc>${baseUrl}/sitemap-pages.xml</loc>
            <lastmod>${new Date().toISOString()}</lastmod>
        </sitemap>
        <sitemap>
            <loc>${baseUrl}/sitemap-categories.xml</loc>
            <lastmod>${new Date().toISOString()}</lastmod>
        </sitemap>
    </sitemapindex>`;

    res.setHeader('Content-Type', 'text/xml');
    res.setHeader('Cache-Control', 'public, s-maxage=3600');
    res.write(sitemapIndex);
    res.end();

    return { props: {} };
}

Robots.txt Configuration

// pages/robots.txt.js
export async function getServerSideProps({ res }) {
    const baseUrl = process.env.NEXT_PUBLIC_SITE_URL;
    const isProduction = process.env.NODE_ENV === 'production';

    const robotsTxt = `# Robots.txt for ${baseUrl}
User-agent: *
${isProduction ? 'Allow: /' : 'Disallow: /'}

# Crawl-delay for bots that respect it
Crawl-delay: 1

# Disallow admin and private areas
Disallow: /api/
Disallow: /admin/
Disallow: /_next/
Disallow: /preview/

# Allow specific bots full access
User-agent: Googlebot
Allow: /

User-agent: Bingbot
Allow: /

# Sitemap location
Sitemap: ${baseUrl}/sitemap-index.xml
Sitemap: ${baseUrl}/news-sitemap.xml

# Block bad bots
User-agent: AhrefsBot
Disallow: /

User-agent: SemrushBot
Crawl-delay: 10
`;

    res.setHeader('Content-Type', 'text/plain');
    res.setHeader('Cache-Control', 'public, s-maxage=86400'); // Cache for 24 hours
    res.write(robotsTxt);
    res.end();

    return { props: {} };
}

Performance Optimization

Core Web Vitals Optimization

// components/Performance/ImageOptimizer.jsx
import { useState, useEffect } from 'react';

export default function ImageOptimizer({ 
    src, 
    alt, 
    width, 
    height, 
    priority = false 
}) {
    const [isInViewport, setIsInViewport] = useState(false);

    useEffect(() => {
        if (priority) {
            setIsInViewport(true);
            return;
        }

        const observer = new IntersectionObserver(
            ([entry]) => {
                if (entry.isIntersecting) {
                    setIsInViewport(true);
                    observer.disconnect();
                }
            },
            { rootMargin: '50px' }
        );

        const element = document.getElementById(`img-${src}`);
        if (element) observer.observe(element);

        return () => observer.disconnect();
    }, [src, priority]);

    return (
        <div id={`img-${src}`} style={{ width, height }}>
            {isInViewport ? (
                <img
                    src={src}
                    alt={alt}
                    width={width}
                    height={height}
                    loading={priority ? 'eager' : 'lazy'}
                    decoding="async"
                />
            ) : (
                <div style={{ backgroundColor: '#f0f0f0', width, height }} />
            )}
        </div>
    );
}

Resource Hints and Preloading

// components/Performance/ResourceHints.jsx
import Head from 'next/head';

export default function ResourceHints({ currentPage }) {
    return (
        <Head>
            {/* DNS Prefetch for external domains */}
            <link rel="dns-prefetch" href="https://fonts.googleapis.com" />
            <link rel="dns-prefetch" href="https://www.google-analytics.com" />

            {/* Preconnect to critical domains */}
            <link rel="preconnect" href="https://fonts.googleapis.com" />
            <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />

            {/* Preload critical resources */}
            <link 
                rel="preload" 
                href="/fonts/inter-var.woff2" 
                as="font" 
                type="font/woff2" 
                crossOrigin=""
            />

            {/* Prefetch likely navigation targets */}
            {currentPage === 'home' && (
                <>
                    <link rel="prefetch" href="/blog" />
                    <link rel="prefetch" href="/about" />
                </>
            )}

            {/* Preload critical CSS */}
            <link 
                rel="preload" 
                href="/_next/static/css/main.css" 
                as="style"
            />
        </Head>
    );
}

Lazy Loading Components

// lib/performance/lazyLoad.js
import dynamic from 'next/dynamic';
import { useState, useEffect } from 'react';

export function useLazyComponent(importFunc, options = {}) {
    const [Component, setComponent] = useState(null);
    const [isInView, setIsInView] = useState(false);

    useEffect(() => {
        if (!isInView) return;

        const LoadComponent = dynamic(importFunc, {
            loading: () => <div>Loading...</div>,
            ...options,
        });

        setComponent(() => LoadComponent);
    }, [isInView, importFunc]);

    return { Component, setIsInView };
}

// Usage
function HomePage() {
    const { Component: HeavyComponent, setIsInView } = useLazyComponent(
        () => import('@/components/HeavyComponent')
    );

    return (
        <div>
            <IntersectionObserver onInView={() => setIsInView(true)}>
                {HeavyComponent && <HeavyComponent />}
            </IntersectionObserver>
        </div>
    );
}

Monitoring and Tools

SEO Monitoring Setup

// lib/monitoring/seoMonitor.js
export class SEOMonitor {
    constructor() {
        this.metrics = {
            pageLoadTime: [],
            renderTime: [],
            crawlErrors: [],
            metaTagsMissing: [],
        };
    }

    trackPageLoad(url, loadTime) {
        this.metrics.pageLoadTime.push({
            url,
            loadTime,
            timestamp: new Date(),
        });

        // Alert if page load is too slow
        if (loadTime > 3000) {
            console.warn(`Slow page load detected: ${url} took ${loadTime}ms`);
            this.reportToMonitoring('slow_page_load', { url, loadTime });
        }
    }

    validateMetaTags(page) {
        const required = ['title', 'description', 'canonical'];
        const missing = required.filter(tag => !page.meta[tag]);

        if (missing.length > 0) {
            this.metrics.metaTagsMissing.push({
                url: page.url,
                missing,
                timestamp: new Date(),
            });
        }

        return missing.length === 0;
    }

    checkStructuredData(page) {
        const scripts = page.querySelectorAll('script[type="application/ld+json"]');
        const schemas = [];

        scripts.forEach(script => {
            try {
                const data = JSON.parse(script.textContent);
                schemas.push(data);

                // Validate schema
                this.validateSchema(data);
            } catch (error) {
                console.error('Invalid structured data:', error);
                this.reportToMonitoring('invalid_schema', { url: page.url, error });
            }
        });

        return schemas;
    }

    validateSchema(schema) {
        // Basic schema validation
        if (!schema['@context'] || !schema['@type']) {
            throw new Error('Missing required schema properties');
        }

        // Type-specific validation
        switch (schema['@type']) {
            case 'Article':
                this.validateArticleSchema(schema);
                break;
            case 'Product':
                this.validateProductSchema(schema);
                break;
        }
    }

    reportToMonitoring(event, data) {
        // Send to monitoring service (e.g., Google Analytics, Sentry)
        if (typeof window !== 'undefined' && window.gtag) {
            window.gtag('event', event, data);
        }
    }
}

Automated SEO Testing

// tests/seo.test.js
import { render } from '@testing-library/react';
import { SEOMonitor } from '@/lib/monitoring/seoMonitor';

describe('SEO Tests', () => {
    const monitor = new SEOMonitor();

    test('All pages have required meta tags', async () => {
        const pages = await getAllPages();

        for (const page of pages) {
            const { container } = render(page.component);
            const head = container.querySelector('head');

            // Check title
            const title = head.querySelector('title');
            expect(title).toBeTruthy();
            expect(title.textContent.length).toBeLessThanOrEqual(60);

            // Check description
            const description = head.querySelector('meta[name="description"]');
            expect(description).toBeTruthy();
            expect(description.content.length).toBeLessThanOrEqual(160);

            // Check canonical
            const canonical = head.querySelector('link[rel="canonical"]');
            expect(canonical).toBeTruthy();
            expect(canonical.href).toMatch(/^https?:\/\//);
        }
    });

    test('Structured data is valid', async () => {
        const pages = await getAllPages();

        for (const page of pages) {
            const { container } = render(page.component);
            const schemas = monitor.checkStructuredData(container);

            expect(schemas.length).toBeGreaterThan(0);
            schemas.forEach(schema => {
                expect(schema['@context']).toBeTruthy();
                expect(schema['@type']).toBeTruthy();
            });
        }
    });

    test('Images have alt text', async () => {
        const pages = await getAllPages();

        for (const page of pages) {
            const { container } = render(page.component);
            const images = container.querySelectorAll('img');

            images.forEach(img => {
                expect(img.alt).toBeTruthy();
                expect(img.alt.length).toBeGreaterThan(0);
            });
        }
    });
});

SEO Dashboard Component

// components/Admin/SEODashboard.jsx
import { useState, useEffect } from 'react';
import { fetchSEOMetrics } from '@/lib/api/seo';

export default function SEODashboard() {
    const [metrics, setMetrics] = useState(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        loadMetrics();
    }, []);

    async function loadMetrics() {
        try {
            const data = await fetchSEOMetrics();
            setMetrics(data);
        } catch (error) {
            console.error('Failed to load SEO metrics:', error);
        } finally {
            setLoading(false);
        }
    }

    if (loading) return <div>Loading SEO metrics...</div>;

    return (
        <div className="seo-dashboard">
            <h2>SEO Health Dashboard</h2>

            <div className="metrics-grid">
                <MetricCard
                    title="Pages Indexed"
                    value={metrics.pagesIndexed}
                    change={metrics.indexedChange}
                />

                <MetricCard
                    title="Average Load Time"
                    value={`${metrics.avgLoadTime}s`}
                    status={metrics.avgLoadTime < 3 ? 'good' : 'warning'}
                />

                <MetricCard
                    title="Missing Meta Tags"
                    value={metrics.missingMeta}
                    status={metrics.missingMeta === 0 ? 'good' : 'error'}
                />

                <MetricCard
                    title="Broken Links"
                    value={metrics.brokenLinks}
                    status={metrics.brokenLinks === 0 ? 'good' : 'error'}
                />
            </div>

            <div className="issues-list">
                <h3>SEO Issues</h3>
                {metrics.issues.map((issue, index) => (
                    <IssueItem key={index} issue={issue} />
                ))}
            </div>

            <div className="recommendations">
                <h3>Recommendations</h3>
                <ul>
                    {metrics.recommendations.map((rec, index) => (
                        <li key={index}>{rec}</li>
                    ))}
                </ul>
            </div>
        </div>
    );
}

Best Practices Checklist

Technical SEO Checklist

## Pre-Launch SEO Checklist

### Technical Foundation
- [ ] Server-side rendering or static generation implemented
- [ ] Clean URL structure with proper routing
- [ ] 301 redirects from old URLs configured
- [ ] Canonical URLs implemented on all pages
- [ ] XML sitemap generated and submitted
- [ ] Robots.txt properly configured
- [ ] SSL certificate installed (HTTPS)

### Meta Tags
- [ ] Unique title tags (50-60 characters)
- [ ] Meta descriptions (150-160 characters)
- [ ] Open Graph tags for social sharing
- [ ] Twitter Card tags implemented
- [ ] Viewport meta tag for mobile

### Structured Data
- [ ] Organization schema on homepage
- [ ] Article schema on blog posts
- [ ] Breadcrumb schema implemented
- [ ] Review schema where applicable
- [ ] FAQ schema for FAQ pages

### Performance
- [ ] Core Web Vitals passing (LCP < 2.5s, FID < 100ms, CLS < 0.1)
- [ ] Images optimized and lazy loaded
- [ ] Critical CSS inlined
- [ ] JavaScript bundles optimized
- [ ] CDN configured for static assets

### Content
- [ ] H1 tags on all pages (only one per page)
- [ ] Proper heading hierarchy (H1 → H2 → H3)
- [ ] Alt text on all images
- [ ] Internal linking strategy implemented
- [ ] 404 page configured

### Monitoring
- [ ] Google Search Console verified
- [ ] Google Analytics/GA4 installed
- [ ] Core Web Vitals monitoring
- [ ] Uptime monitoring configured
- [ ] Error tracking implemented

Conclusion

SEO for headless WordPress requires careful planning and implementation, but the rewards are significant. By leveraging server-side rendering, implementing comprehensive meta tags and structured data, optimizing performance, and monitoring key metrics, you can achieve excellent search visibility while maintaining the flexibility and performance benefits of a headless architecture.

Key takeaways: - Always implement server-side rendering or static generation for SEO - Comprehensive meta tag and structured data implementation is crucial - Performance optimization directly impacts SEO rankings - Regular monitoring and testing ensure ongoing SEO health - The flexibility of headless architecture allows for advanced SEO strategies

The extra effort required for headless SEO implementation is offset by superior performance, better user experience, and the ability to implement cutting-edge SEO techniques that wouldn't be possible with traditional WordPress.


Continue your headless WordPress journey with our comprehensive Headless WordPress Guide for more implementation strategies and best practices.