Technical Guide

How We Built a Headless WordPress Blog on a React SPA Using Vercel's Multi-Zone Architecture

If you've ever run a blog on a subdomain while your main application lives on the root domain, you've likely encountered a frustrating SEO reality: Google treats subdomains as separate websites. Here's how we solved it with headless WordPress and Vercel's multi-zone architecture.

AEO Engine Team··18 min read
Last verified: January 2026

The SEO Problem We Solved

If you've ever run a blog on a subdomain (blog.yourdomain.com) while your main application lives on the root domain (yourdomain.com), you've likely encountered a frustrating SEO reality: Google treats subdomains as separate websites. According to Google's documentation on URL structures, subdomains and subdirectories can be treated differently for ranking purposes.

This means your blog's authority doesn't flow to your main site, and vice versa. You're essentially splitting your domain equity in half.

The solution? Move your blog to a subfolder (yourdomain.com/blog/*). But if your main app is a React SPA and you want WordPress for content management, how do you make that work without rebuilding everything?

Enter the headless CMS architecture with Vercel's multi-zone rewrites.

In this guide, we'll walk through exactly how to serve a Next.js-powered blog (backed by WordPress) from a subfolder on your React SPA domain—without sacrificing performance, SEO, or developer experience.

Architecture Overview

Here's the high-level view of what we're building:

┌─────────────────────────────────────────────────────────────────┐
│                    User Request Flow                            │
└─────────────────────────────────────────────────────────────────┘

  User visits yourdomain.com/blog/post-title
                     │
                     ▼
    ┌────────────────────────────────┐
    │     Vercel Edge Network        │
    │    (Main App Project)          │
    └────────────────────────────────┘
                     │
                     │ Rewrite rule matches /blog/*
                     ▼
    ┌────────────────────────────────┐
    │   Next.js Blog Frontend        │
    │   (Separate Vercel Project)    │
    └────────────────────────────────┘
                     │
                     │ Fetches content via REST API
                     ▼
    ┌────────────────────────────────┐
    │   WordPress (Headless CMS)     │
    │   Admin + REST API only        │
    └────────────────────────────────┘
                     │
                     ▼
    ┌────────────────────────────────┐
    │   SSR HTML returned to user    │
    │   (Looks like yourdomain.com)  │
    └────────────────────────────────┘
Request flow from user to SSR response

The Three Components

ComponentTechnologyPurpose
Main ApplicationVite/React SPAYour existing web app
Blog FrontendNext.js (App Router)SSR blog pages with SEO benefits
CMS BackendWordPressContent management via REST API

The magic happens at the edge: Vercel's rewrite rules transparently proxy /blog/* requests to your Next.js project, while users see everything under your main domain.

Why This Architecture?

Before diving into implementation, let's understand why each technology choice matters:

Why Headless WordPress?

WordPress powers over 40% of the web for good reason—it's an excellent content management system. But traditional WordPress has SEO limitations:

  • Server-side rendering depends on PHP performance
  • Theme conflicts can break Core Web Vitals
  • Plugin bloat affects load times

By using WordPress as a headless CMS with its REST API, we get:

  • Familiar editing experience for content teams
  • REST API for flexible data access
  • Decoupled frontend for performance optimization

Why Next.js for the Blog Frontend?

React SPAs are great for applications, but they're not ideal for content-heavy pages that need SEO:

  • Client-side rendering means slower First Contentful Paint
  • Search engines may not fully index JavaScript-rendered content
  • No native support for meta tags and structured data

Next.js with the App Router provides:

  • Server-side rendering for instant content delivery
  • Native metadata API for SEO tags
  • Static generation options for even faster performance
  • Incremental Static Regeneration for cache freshness

Why Vercel Multi-Zone Rewrites?

The alternative approaches all have drawbacks:

ApproachProblem
Subdomain blogSplits domain authority
Monorepo with both appsComplex deployments, shared dependencies
Nginx reverse proxyAdditional infrastructure to manage
iframe embeddingSEO disaster, poor UX

Vercel's rewrite rules give us:

  • Zero-config edge routing at the CDN level
  • Independent deployments for each project
  • Transparent URL structure for users and search engines
  • No additional infrastructure to maintain

Step 1: Configure WordPress as a Headless CMS

Your WordPress installation will handle only two things: the admin dashboard and the REST API.

Basic Setup

  1. Install WordPress on your hosting provider
  2. Navigate to Settings → Permalinks
  3. Select "Post Name" (/%postname%/)

This ensures your REST API returns clean slugs that match your frontend URLs.

REST API Access

The WordPress REST API is available at:

text
https://your-wordpress-domain.com/wp-json/wp/v2/

Key endpoints you'll use:

EndpointPurpose
/wp-json/wp/v2/postsList and retrieve posts
/wp-json/wp/v2/posts?slug=your-postGet post by slug
/wp-json/wp/v2/categoriesList categories
/wp-json/wp/v2/tagsList tags

Optional: Yoast SEO Integration

If you use Yoast SEO, the plugin automatically exposes metadata through the REST API:

Yoast REST API Response
{
  "yoast_head_json": {
    "title": "Your SEO Title",
    "description": "Your meta description",
    "og_title": "Open Graph Title",
    "og_description": "Open Graph Description"
  }
}

This lets you pass through optimized metadata from WordPress to your Next.js frontend.

Step 2: Build the Next.js Blog Frontend

Create a new Next.js project using the App Router:

bash
npx create-next-app@latest blog-frontend --typescript --tailwind --app
cd blog-frontend

Project Structure

Project Structure
blog-frontend/
├── app/
│   ├── page.tsx                    # Root redirect to /blog
│   ├── layout.tsx                  # Root layout
│   ├── globals.css                 # Styles
│   └── blog/
│       ├── page.tsx                # Blog index (lists posts)
│       ├── layout.tsx              # Blog layout with header/footer
│       ├── [slug]/page.tsx         # Individual post page
│       └── sitemap.xml/route.ts    # Dynamic XML sitemap
├── lib/
│   └── wordpress.ts                # WordPress API helper functions
├── next.config.ts                  # Next.js configuration
└── vercel.json                     # Vercel-specific config

Environment Variables

Set these in your Vercel project settings:

.env
WP_BASE_URL=https://your-wordpress-domain.com
PUBLIC_SITE_URL=https://yourdomain.com

WordPress API Helper

Create a type-safe helper for fetching from WordPress:

lib/wordpress.ts
export const WP_BASE_URL = process.env.WP_BASE_URL!;
export const PUBLIC_SITE_URL = process.env.PUBLIC_SITE_URL!;

export type WPPost = {
  id: number;
  slug: string;
  date: string;
  modified: string;
  title: { rendered: string };
  excerpt: { rendered: string };
  content: { rendered: string };
  yoast_head_json?: {
    title?: string;
    description?: string;
    og_title?: string;
    og_description?: string;
  };
};

export async function fetchFromWordPress<T>(path: string): Promise<T> {
  const response = await fetch(`${WP_BASE_URL}${path}`, {
    next: { revalidate: 300 }  // Cache for 5 minutes, then revalidate
  });

  if (!response.ok) {
    throw new Error(`WordPress API error: ${response.status}`);
  }

  return response.json() as Promise<T>;
}

// WordPress returns HTML-encoded titles—decode them for display
export function decodeHtmlEntities(input: string): string {
  return input
    .replace(/&amp;/g, "&")
    .replace(/&#038;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#039;/g, "'")
    .replace(/&nbsp;/g, " ");
}

Blog Index Page

app/blog/page.tsx
import Link from "next/link";
import { fetchFromWordPress, decodeHtmlEntities, WPPost } from "@/lib/wordpress";

export const metadata = {
  title: "Blog | Your Site Name",
  description: "Latest articles and insights",
};

export default async function BlogIndex() {
  const posts = await fetchFromWordPress<WPPost[]>(
    "/wp-json/wp/v2/posts?per_page=20&_embed"
  );

  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold mb-8">Blog</h1>

      <div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
        {posts.map((post) => (
          <article key={post.id} className="border rounded-lg p-6">
            <Link href={`/blog/${post.slug}`}>
              <h2 className="text-xl font-semibold hover:text-blue-600">
                {decodeHtmlEntities(post.title.rendered)}
              </h2>
            </Link>
            <time className="text-gray-500 text-sm">
              {new Date(post.date).toLocaleDateString()}
            </time>
            <div
              className="mt-4 text-gray-600"
              dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }}
            />
          </article>
        ))}
      </div>
    </main>
  );
}

Individual Post Page

app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { fetchFromWordPress, decodeHtmlEntities, WPPost, PUBLIC_SITE_URL } from "@/lib/wordpress";
import type { Metadata } from "next";

type Props = {
  params: Promise<{ slug: string }>;
};

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;

  try {
    const posts = await fetchFromWordPress<WPPost[]>(
      `/wp-json/wp/v2/posts?slug=${slug}`
    );

    if (!posts.length) return { title: "Post Not Found" };

    const post = posts[0];
    const yoast = post.yoast_head_json;

    return {
      title: yoast?.title || decodeHtmlEntities(post.title.rendered),
      description: yoast?.description || "",
      openGraph: {
        title: yoast?.og_title || decodeHtmlEntities(post.title.rendered),
        description: yoast?.og_description || "",
        url: `${PUBLIC_SITE_URL}/blog/${slug}`,
        type: "article",
      },
      alternates: {
        canonical: `${PUBLIC_SITE_URL}/blog/${slug}`,
      },
    };
  } catch {
    return { title: "Post Not Found" };
  }
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params;

  const posts = await fetchFromWordPress<WPPost[]>(
    `/wp-json/wp/v2/posts?slug=${slug}`
  );

  if (!posts.length) {
    notFound();
  }

  const post = posts[0];

  return (
    <article className="container mx-auto px-4 py-8 max-w-3xl">
      <h1 className="text-4xl font-bold mb-4">
        {decodeHtmlEntities(post.title.rendered)}
      </h1>

      <time className="text-gray-500">
        {new Date(post.date).toLocaleDateString()}
      </time>

      <div
        className="prose prose-lg mt-8"
        dangerouslySetInnerHTML={{ __html: post.content.rendered }}
      />
    </article>
  );
}

Dynamic Sitemap

Create a route handler that generates XML sitemaps from your WordPress posts:

app/blog/sitemap.xml/route.ts
import { fetchFromWordPress, WPPost, PUBLIC_SITE_URL } from "@/lib/wordpress";

export async function GET() {
  const posts = await fetchFromWordPress<WPPost[]>(
    "/wp-json/wp/v2/posts?per_page=100&_fields=slug,modified"
  );

  const urls = posts
    .map((post) => {
      const loc = `${PUBLIC_SITE_URL}/blog/${post.slug}`;
      const lastmod = new Date(post.modified).toISOString();
      return `<url><loc>${escapeXml(loc)}</loc><lastmod>${lastmod}</lastmod></url>`;
    })
    .join("");

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`;

  return new Response(xml, {
    headers: {
      "Content-Type": "application/xml",
      "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
    },
  });
}

function escapeXml(str: string): string {
  return str
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");
}

Next.js Configuration

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // Consistent URL handling—no trailing slashes
  trailingSlash: false,

  reactStrictMode: true,

  // Allow WordPress images from common CDN domains
  images: {
    remotePatterns: [
      { protocol: "https", hostname: "**.wordpress.com" },
      { protocol: "https", hostname: "**.wp.com" },
      { protocol: "https", hostname: "secure.gravatar.com" },
      { protocol: "https", hostname: "i0.wp.com" },
      { protocol: "https", hostname: "i1.wp.com" },
      { protocol: "https", hostname: "i2.wp.com" },
    ],
  },

  // URL normalization redirects
  async redirects() {
    return [
      // Root of this project redirects to /blog
      {
        source: "/",
        destination: "/blog",
        permanent: true,
      },
      // Normalize trailing slashes with 308 redirect
      {
        source: "/blog/:slug/",
        destination: "/blog/:slug",
        permanent: true,
      },
    ];
  },

  // Cache control headers for CDN
  async headers() {
    return [
      {
        source: "/blog/:path*",
        headers: [
          {
            key: "Cache-Control",
            value: "public, max-age=0, s-maxage=60, stale-while-revalidate=120",
          },
        ],
      },
    ];
  },
};

export default nextConfig;

Step 3: Configure Multi-Zone Rewrites

This is where the magic happens. In your main application's vercel.json, add rewrite rules that proxy /blog/* requests to your Next.js blog project:

vercel.json (Main App)
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "rewrites": [
    {
      "source": "/blog/sitemap.xml",
      "destination": "https://your-blog-frontend.vercel.app/blog/sitemap.xml"
    },
    {
      "source": "/blog",
      "destination": "https://your-blog-frontend.vercel.app/blog"
    },
    {
      "source": "/blog/:path*",
      "destination": "https://your-blog-frontend.vercel.app/blog/:path*"
    },
    {
      "source": "/_next/:path*",
      "destination": "https://your-blog-frontend.vercel.app/_next/:path*"
    }
  ]
}

Why Rewrites Instead of Redirects?

  • Rewrites happen at the edge and are invisible to users—the URL stays as yourdomain.com/blog/post
  • Redirects would change the URL and break the subfolder strategy

Step 4: Prevent SPA Route Conflicts

If your main app is a React SPA with client-side routing, you need to ensure it doesn't intercept /blog/* URLs.

Remove /blog from Your SPA Router

In your React Router (or equivalent) configuration, make sure there's no /blog route:

React Router Config
// ❌ Remove this
<Route path="/blog" element={<Blog />} />

// ✅ Your router should NOT handle /blog
<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/dashboard" element={<Dashboard />} />
  {/* /blog is handled by Next.js via Vercel rewrite */}
  <Route path="*" element={<NotFound />} />
</Routes>

Exclude /blog from Service Worker Caching

If your SPA uses a service worker (common in PWAs), it may cache the SPA shell and serve it for all navigation requests—including /blog.

In your Vite PWA configuration:

vite.config.ts
VitePWA({
  registerType: "autoUpdate",
  workbox: {
    // CRITICAL: Exclude /blog from SPA navigation fallback
    navigateFallbackDenylist: [/^\/blog/, /^\/blog\/.*/],

    globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
  },
})

This tells the service worker: "For URLs starting with /blog, don't serve the cached SPA—let the request go to the network."

Step 5: SEO Configuration

With your architecture in place, it's time to configure SEO settings. Proper schema markup and indexing headers are critical for visibility in both traditional search and AI-powered answer engines.

Ensure Proper Indexing Headers

Both your blog frontend and main app should send appropriate headers:

json
{
  "headers": [
    {
      "source": "/blog/:path*",
      "headers": [
        {
          "key": "X-Robots-Tag",
          "value": "index, follow"
        }
      ]
    }
  ]
}

Canonical URLs

Every blog post should have a canonical URL pointing to your main domain:

html
<link rel="canonical" href="https://yourdomain.com/blog/post-slug" />

This is handled automatically by Next.js when you set alternates.canonical in your metadata.

Submit Your Sitemap

Add your blog sitemap to Google Search Console:

text
https://yourdomain.com/blog/sitemap.xml

Troubleshooting Common Issues

Problem: /blog shows SPA content instead of Next.js blog

Symptoms: Visiting /blog shows your React app's placeholder or 404 page.

Causes and solutions:

CauseSolution
SPA has a /blog routeRemove from your router configuration
Service worker cachingAdd navigateFallbackDenylist for /blog
Browser cacheClear site data in DevTools → Application → Storage
Vercel edge cacheRedeploy or purge cache in Vercel dashboard

Problem: Blog pages show noindex in Google Search Console

Cause: Some deployment protection settings can add X-Robots-Tag: noindex to responses.

Solution: For public content, ensure your blog frontend project has appropriate protection settings that allow public access to production deployments.

Problem: Trailing slash URLs return 404

Solution: Add redirect rules in both next.config.ts and vercel.json:

json
{
  "source": "/blog/:slug/",
  "destination": "/blog/:slug",
  "permanent": true
}

Problem: WordPress REST API returns 401 Unauthorized

For read operations: Check that security plugins aren't blocking public API access. WordPress core only requires authentication for write operations.

For write operations: Use WordPress Application Passwords (built-in since WordPress 5.6) for authenticated requests.

Verification Checklist

Before going live, verify your setup with these commands:

bash
# Check blog index loads correctly
curl -I https://yourdomain.com/blog

# Check individual post
curl -I https://yourdomain.com/blog/your-post-slug

# Check trailing slash redirect (should return 308)
curl -I https://yourdomain.com/blog/your-post-slug/

# Check sitemap is accessible
curl https://yourdomain.com/blog/sitemap.xml | head -20

# Check robots tag
curl -I https://yourdomain.com/blog | grep -i robots
# Should return: X-Robots-Tag: index, follow

Performance Benefits

This architecture delivers excellent Core Web Vitals:

MetricWhy It's Fast
LCPSSR delivers content immediately; no client-side hydration delay
FIDMinimal JavaScript; most interactivity is native HTML
CLSServer-rendered layout; no layout shifts from async content
TTFBEdge caching with stale-while-revalidate ensures fast responses

Summary

Here's what each layer does in this architecture:

LayerResponsibility
WordPressContent management—admin dashboard + REST API
Next.js Blog FrontendSSR blog pages, fetches from WordPress
Main App vercel.jsonRewrites /blog/* to blog frontend
Main App SPA RouterDoes NOT handle /blog routes
Main App Service WorkerExcludes /blog from caching

This gives you:

SEO benefits of subfolder blog on main domain
Full SSR for blog content (better than client-rendered)
WordPress as familiar CMS for content teams
React SPA for your main application
Independent deployments for blog and main app
Edge caching for fast global performance

Next Steps

Ready to implement this architecture? Here's your action plan:

  1. Set up WordPress as a headless CMS with REST API access
  2. Create your Next.js blog frontend with the code patterns above
  3. Configure rewrites in your main app's vercel.json
  4. Remove /blog routes from your SPA and service worker
  5. Deploy and verify with the checklist commands
  6. Submit your sitemap to Google Search Console

Want to optimize your blog further for Answer Engine Optimization? Try our free Schema Markup Generator to create structured data that improves your content's visibility in AI-powered search results.

Too Complex? Let Us Handle It.

Implementing this architecture requires expertise in WordPress, Next.js, Vercel, and SEO best practices. If you'd rather focus on your business while we handle the technical heavy lifting, our team can implement this entire setup for you—plus optimize your content for Answer Engine Optimization so you rank in both Google and AI-powered search engines like ChatGPT, Perplexity, and Claude.

Unleash the AEO Engine to grow your organic traffic on autopilot.

Book A Free Strategy Call

This guide is based on our actual production implementation and not theoretical. The architecture has been battle-tested across multiple deployments and optimized for both developer experience and SEO performance.

About the Author

This guide was written by the AEO Engine engineering team based on our production implementation powering this very blog. We specialize in SEO-first architecture for modern web applications, helping teams build content strategies that rank in both traditional search engines and AI-powered answer engines.