A Kerala media company I worked with was running a news portal on traditional WordPress. Their editors loved the CMS — familiar interface, easy scheduling, media library they'd been using for years. But page load times had crept up to 3.2 seconds on mobile, caching was a constant battle, and every plugin update risked breaking the site. The solution wasn't to abandon WordPress. It was to separate its two jobs: content management and content delivery. After moving to a decoupled architecture with WPGraphQL and Next.js, the same content now loads in 0.8 seconds. The editors notice nothing different. The readers notice everything.
Why Go Headless? The Real Reasons Beyond Hype
The WordPress REST API has been around since 2016. It works, but it has a fundamental shape problem. Every REST request for a blog post returns a large JSON blob that includes fields you don't need — author metadata, embedded taxonomy objects, rendered content, raw content, link collections. Fetching a post list for a homepage means pulling down 50+ fields per post when you need five.
WPGraphQL flips this. You declare exactly the fields your component needs, and the server returns only those. For a post card showing title, excerpt, featured image URL, and slug, your query returns exactly those four fields. On a news site with 200+ cards on a category page, the bandwidth difference is material — GraphQL requests were roughly 60% smaller than equivalent REST calls in the Kerala media project.
The performance improvement came from two places: smaller payloads and Next.js's static generation. Once a page is built, it's served as pre-rendered HTML from Vercel's edge network. There's no PHP execution, no MySQL query, no WordPress plugin overhead on each request. The 0.8-second load time isn't a tuned-WordPress result — it's a fundamentally different delivery model.
That said, headless is overkill for a large portion of Kerala businesses. A 10-page service website for a law firm, clinic, or coaching centre has no business going headless. Traditional WordPress with WP Rocket and a good host will score 85+ on Lighthouse and never strain under normal traffic. The effort of building and maintaining a decoupled setup — two separate deployments, two sets of environment variables, GraphQL schema management — only pays off when content velocity, traffic volume, or multi-platform delivery demands it.
The Stack: WordPress + WPGraphQL + Next.js + Vercel
Each piece in this architecture has a distinct job and they don't overlap:
- WordPress: Content creation, editorial workflow, media management, user authentication. Your editors interact only with this layer. It has no public-facing frontend.
- WPGraphQL: A WordPress plugin that introspects your content model — posts, pages, custom post types, taxonomies — and exposes a GraphQL endpoint at
/graphql. This is the API layer between WordPress and your frontend. - Next.js: The frontend application. It queries WPGraphQL at build time (for static pages) or at request time (for dynamic content), renders React components, and handles routing, image optimisation, and metadata.
- Vercel: Deployment and edge delivery for the Next.js frontend. Handles CDN, SSL, and preview deployments automatically.
WordPress itself runs on a standard VPS — DigitalOcean, Hostinger, or Hetzner work fine. It does not need to be a premium managed WordPress host because it's never serving public traffic. Only your Next.js build process (and your editors) ever talk to it.
If Vercel's pricing in INR feels steep for a growing project, Cloudflare Pages is a strong alternative. It offers unlimited bandwidth on the free tier and Pages Functions for server-side logic. The tradeoff is that Cloudflare's Next.js support lags slightly behind Vercel's — some advanced App Router features need workarounds. For most headless WordPress projects, Cloudflare Pages works well and significantly reduces infrastructure costs.
Step 1: Set Up WordPress as a Headless CMS
Install WordPress on your server as normal — cPanel, Docker, or a plain LEMP stack. Then install the WPGraphQL plugin from the WordPress plugin directory (it's free). Once activated, it adds a GraphQL endpoint at yourwordpress.com/graphql and a GraphiQL IDE in your WordPress admin for testing queries.
CORS is the first configuration hurdle. Your Next.js app running on a different domain needs permission to query WordPress's GraphQL endpoint. Add this to your theme's functions.php or a custom plugin:
add_action('graphql_response_headers', function($headers) {
$allowed_origin = 'https://your-nextjs-frontend.vercel.app';
$headers['Access-Control-Allow-Origin'] = $allowed_origin;
$headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization';
return $headers;
});
For local development, add http://localhost:3000 to the allowed origins. In production, lock it to your actual Next.js domain — a wildcard * is fine during development but should never go to production if your WordPress has any private content.
Next, create an Application Password for the Next.js frontend. Go to WordPress Admin > Users > Your Profile > Application Passwords. Generate a password named "Next.js Frontend". This credential goes into your Next.js environment variables as WORDPRESS_AUTH_REFRESH_TOKEN or similar, and is used only when fetching authenticated content like draft posts during preview mode. Public queries don't need authentication.
Step 2: Build the Next.js Frontend
Create a Next.js 15 project with the App Router:
npx create-next-app@latest my-headless-site \
--typescript \
--app \
--tailwind \
--no-src-dir
For querying WPGraphQL, you have two main options. graphql-request is a lightweight library (about 7KB) that's well-suited for Next.js Server Components where you just need to fire a query and get data. @apollo/client adds a full client-side cache and is useful if you have interactive GraphQL queries that update in the browser. For most headless WordPress builds, graphql-request is the right choice:
npm install graphql-request graphql
Create a GraphQL client utility:
// lib/wordpress.ts
import { GraphQLClient } from 'graphql-request';
const endpoint = process.env.NEXT_PUBLIC_WORDPRESS_API_URL!;
export const client = new GraphQLClient(endpoint, {
headers: {
'Content-Type': 'application/json',
},
});
Your first query — fetching a list of posts for the blog index:
// lib/queries.ts
export const GET_POSTS = gql`
query GetPosts($first: Int = 10, $after: String) {
posts(first: $first, after: $after, where: { status: PUBLISH }) {
pageInfo {
hasNextPage
endCursor
}
nodes {
databaseId
slug
title
excerpt
date
featuredImage {
node {
sourceUrl(size: MEDIUM_LARGE)
altText
}
}
categories {
nodes {
name
slug
}
}
}
}
}
`;
For custom post types — say, a "Case Studies" CPT you've registered in WordPress — WPGraphQL exposes them automatically once you add 'show_in_graphql' => true to the post type registration arguments.
Step 3: Incremental Static Regeneration for News and Blog Sites
Static generation pre-builds every post page at deploy time. For a 500-post site, that's 500 HTML files generated once and served instantly forever — until content changes. The challenge is revalidation: when an editor updates a post in WordPress, the cached HTML is stale.
Next.js's Incremental Static Regeneration solves this without rebuilding the entire site. For time-based revalidation, add a revalidate option to your fetch calls:
// app/blog/[slug]/page.tsx
export default async function PostPage({ params }: { params: { slug: string } }) {
const data = await client.request(GET_POST_BY_SLUG, {
slug: params.slug,
}, {
cache: 'force-cache',
next: { revalidate: 3600 }, // rebuild this page at most once per hour
});
// render post...
}
For a Kerala news site publishing 200 posts per day, time-based revalidation with a 10–15 minute window is the right balance. A breaking news post might sit stale for 10 minutes — acceptable for most publishers. If you need immediate revalidation when an editor hits "Publish" in WordPress, use on-demand revalidation with a webhook. WordPress sends a POST to a Next.js API route:
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-revalidate-secret');
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ message: 'Invalid secret' }, { status: 401 });
}
const body = await request.json();
const slug = body.post?.post_name;
if (slug) {
revalidatePath(`/blog/${slug}`);
revalidateTag('posts');
}
return Response.json({ revalidated: true });
}
Configure WordPress to call this endpoint using a plugin like WP Webhooks or a custom function hooked to save_post. Full SSR (Server-Side Rendering on every request) is rarely the right choice for a content-heavy site — you lose the edge caching advantage that makes Next.js fast, and every page load hits your WordPress server. ISR gives you static-site speed with content freshness.
Handling WordPress Authentication
Most headless WordPress sites serve public content and need no authentication at all. The interesting case is membership sites — paid newsletters, subscriber-only archives, community platforms — where some content should be gated.
The cleanest pattern uses the Simple JWT Authentication for WP REST API plugin (also works with WPGraphQL). Your Next.js frontend sends credentials to WordPress, receives a JWT, and stores it in an HttpOnly cookie via a Next.js API route. Subsequent WPGraphQL queries include the JWT in the Authorization header to fetch gated content.
For route protection in Next.js, middleware checks the cookie:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('wp-auth-token');
const isProtectedRoute = request.nextUrl.pathname.startsWith('/members');
if (isProtectedRoute && !token) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/members/:path*'],
};
This keeps authentication logic at the edge — requests never reach your page components unless the user has a valid token. WordPress continues to handle user accounts, subscription status, and password management through its native user system.
SEO Considerations for Headless WordPress
The biggest SEO concern editors raise when moving to headless WordPress is losing Yoast. Yoast's value is in two areas: the writing interface that prompts editors to fill in meta descriptions, and the technical output (meta tags, schema). The writing interface is gone. The technical output you replicate in Next.js — but you control it more precisely.
Next.js 15's Metadata API handles title and OG tags per page:
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: { params: { slug: string } }) {
const { post } = await client.request(GET_POST_META, { slug: params.slug });
return {
title: post.seo?.title || post.title,
description: post.seo?.metaDesc || post.excerpt,
openGraph: {
title: post.seo?.opengraphTitle || post.title,
description: post.seo?.opengraphDescription || post.excerpt,
images: [post.featuredImage?.node?.sourceUrl],
publishedTime: post.date,
},
alternates: {
canonical: `https://rajeshrnair.com/blog/${post.slug}`,
},
};
}
If your editors are Yoast users, install the WPGraphQL for Yoast SEO plugin. It exposes Yoast's SEO fields (title, metaDesc, opengraphTitle, etc.) through your GraphQL API, so editors keep their familiar Yoast panel and the metadata flows through to Next.js automatically.
For multilingual sites serving both Malayalam and English content, add hreflang links in generateMetadata. Sitemap generation with next-sitemap queries WPGraphQL at build time to produce a complete sitemap with all post and page URLs — no Yoast sitemap needed.
Deployment and Cost
Running this architecture involves two separate services with separate billing:
WordPress backend (VPS): A ₹800–1,200/month VPS on Hostinger or Hetzner is sufficient for the headless WordPress layer. It handles only admin traffic from your editors and build-time queries from Next.js deployments. No public traffic, no caching infrastructure needed. SiteGround's managed WordPress starts at ₹1,400/month and adds automatic updates and better support, which is worth considering for clients who manage their own WordPress.
Next.js frontend (Vercel): Vercel's free Hobby plan covers most small projects — 100GB bandwidth, 100 deployments per day. Growing sites will hit limits and need the Pro plan at approximately ₹1,700/month. Cloudflare Pages is free for unlimited bandwidth if cost is a concern, with a small tradeoff in Next.js compatibility.
Total monthly: ₹800–2,900 depending on your VPS and Vercel plan. Compare that to premium managed WordPress hosting like WP Engine or Kinsta, which start at ₹2,800–5,000/month and still deliver slower, server-rendered pages. The headless stack is not just faster — for larger sites, it's cheaper.
Frequently Asked Questions
Does headless WordPress hurt SEO compared to traditional WordPress?
No — with a proper Next.js metadata setup, headless can outperform traditional WordPress on SEO. Next.js pre-renders pages as static HTML that search engines crawl without any JavaScript dependency. The real trade-off is operational: you lose Yoast's editor-facing prompts and must ensure developers implement metadata rigorously. Pairing WPGraphQL with the WPGraphQL for Yoast SEO plugin gives editors their familiar interface while feeding the correct meta values to Next.js.
Can I still use Elementor or WooCommerce with headless WordPress?
Elementor content does not transfer cleanly. Elementor stores layout data in WordPress post meta as serialised arrays — not in a format Next.js can render as React components. Any page built with Elementor would need to be reconstructed as a React component. WooCommerce is more viable: it exposes products, orders, and cart data through its REST API and the WooCommerce GraphQL extension, but you must build your own cart UI and checkout flow in Next.js. Budget 40–80 hours of developer time for a basic WooCommerce headless setup.
What's the minimum traffic level that justifies moving to headless WordPress?
For content and news sites, roughly 100,000 pageviews per month is where headless starts paying for the added complexity. Below that, a well-configured traditional WordPress with WP Rocket and Cloudflare handles the load fine. The stronger case for going headless comes from architectural needs rather than traffic: if you are building a native mobile app alongside your website and both need to draw from the same content backend, headless pays off immediately — the same WPGraphQL endpoint serves both. Similarly, if you need to publish the same content to your website, an email newsletter platform, and a digital display, a single API-driven backend is far simpler to maintain than three separate WordPress instances.