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.
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) │
└────────────────────────────────┘The Three Components
| Component | Technology | Purpose |
|---|---|---|
| Main Application | Vite/React SPA | Your existing web app |
| Blog Frontend | Next.js (App Router) | SSR blog pages with SEO benefits |
| CMS Backend | WordPress | Content 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:
| Approach | Problem |
|---|---|
| Subdomain blog | Splits domain authority |
| Monorepo with both apps | Complex deployments, shared dependencies |
| Nginx reverse proxy | Additional infrastructure to manage |
| iframe embedding | SEO 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
- Install WordPress on your hosting provider
- Navigate to Settings → Permalinks
- 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:
https://your-wordpress-domain.com/wp-json/wp/v2/Key endpoints you'll use:
| Endpoint | Purpose |
|---|---|
| /wp-json/wp/v2/posts | List and retrieve posts |
| /wp-json/wp/v2/posts?slug=your-post | Get post by slug |
| /wp-json/wp/v2/categories | List categories |
| /wp-json/wp/v2/tags | List tags |
Optional: Yoast SEO Integration
If you use Yoast SEO, the plugin automatically exposes metadata through the REST API:
{
"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:
npx create-next-app@latest blog-frontend --typescript --tailwind --app
cd blog-frontendProject 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 configEnvironment Variables
Set these in your Vercel project settings:
WP_BASE_URL=https://your-wordpress-domain.com
PUBLIC_SITE_URL=https://yourdomain.comWordPress API Helper
Create a type-safe helper for fetching from WordPress:
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(/&/g, "&")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/ /g, " ");
}Blog Index Page
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
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:
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, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}Next.js Configuration
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:
{
"$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:
// ❌ 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:
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:
{
"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:
<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:
https://yourdomain.com/blog/sitemap.xmlTroubleshooting 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:
| Cause | Solution |
|---|---|
| SPA has a /blog route | Remove from your router configuration |
| Service worker caching | Add navigateFallbackDenylist for /blog |
| Browser cache | Clear site data in DevTools → Application → Storage |
| Vercel edge cache | Redeploy 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:
{
"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:
# 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, followPerformance Benefits
This architecture delivers excellent Core Web Vitals:
| Metric | Why It's Fast |
|---|---|
| LCP | SSR delivers content immediately; no client-side hydration delay |
| FID | Minimal JavaScript; most interactivity is native HTML |
| CLS | Server-rendered layout; no layout shifts from async content |
| TTFB | Edge caching with stale-while-revalidate ensures fast responses |
Summary
Here's what each layer does in this architecture:
| Layer | Responsibility |
|---|---|
| WordPress | Content management—admin dashboard + REST API |
| Next.js Blog Frontend | SSR blog pages, fetches from WordPress |
| Main App vercel.json | Rewrites /blog/* to blog frontend |
| Main App SPA Router | Does NOT handle /blog routes |
| Main App Service Worker | Excludes /blog from caching |
This gives you:
Next Steps
Ready to implement this architecture? Here's your action plan:
- Set up WordPress as a headless CMS with REST API access
- Create your Next.js blog frontend with the code patterns above
- Configure rewrites in your main app's
vercel.json - Remove /blog routes from your SPA and service worker
- Deploy and verify with the checklist commands
- 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 CallThis 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.