A practical step-by-step guide to migrating your WordPress site to a headless Next.js front-end — covering WPGraphQL, ISR, menus, images, and when headless makes sense. This practical guide covers step-by-step setup, real-world code snippets, security hardening, and common production edge cases.
Introduction
Headless WordPress decouples the content management backend (WordPress) from the front-end presentation (a framework like Next.js). Your editors keep the familiar WordPress dashboard — ACF, Gutenberg, WooCommerce — while your front-end gains the full power of React: better performance, type safety, edge caching, and modern DX.
This guide walks through a real migration from a traditional WordPress site to a Next.js 15 headless front-end using WPGraphQL.
Prerequisites
- An existing WordPress site (self-hosted, WP Engine, Kinsta, etc.)
- Node.js 18+ on your development machine
- Basic familiarity with Next.js App Router
- Administrator access to your WordPress backend
1. Should You Go Headless?
Headless WordPress is not always the right choice. Consider it when:
| ✅ Good fit | ❌ Not a good fit |
|---|---|
| High-traffic sites needing edge caching | Small blogs with simple needs |
| Custom front-end design beyond themes | Heavy reliance on WordPress plugins that affect the front-end |
| Multiple front-ends (web, app, kiosk) | Clients who need to manage their own hosting easily |
| Developer team comfortable with React | Budget for managing two separate deployments |
| Performance is critical (Core Web Vitals) | WooCommerce checkout (complex to decouple) |
2. Setting Up WordPress as a Headless CMS
Install WPGraphQL
WPGraphQL is the most popular solution for querying WordPress content via GraphQL:
- Install the WPGraphQL plugin from the WordPress plugin directory
- Activate it — a new GraphQL menu appears in your admin
- Visit GraphQL → Settings and note your endpoint:
https://yoursite.com/graphql
Optional: WPGraphQL for ACF
If you use Advanced Custom Fields:
# Install via admin or use git
# Plugin: WPGraphQL for ACF
This exposes your custom fields in the GraphQL schema.
Enable Preview Mode
In WordPress, go to Settings → GraphQL → Settings and enable:
- Public Introspection (for development only — disable in production)
- Enabled Previews (for draft preview support)
3. Create the Next.js Project
npx create-next-app@latest my-headless-wp --typescript --tailwind --app
cd my-headless-wp
npm install graphql-request graphql
Set environment variables:
# .env.local
WORDPRESS_API_URL=https://yoursite.com/graphql
WORDPRESS_PREVIEW_SECRET=your_random_preview_secret
4. Querying Posts with WPGraphQL
Create a GraphQL client
// lib/graphql.ts
import { GraphQLClient } from 'graphql-request';
export const client = new GraphQLClient(
process.env.WORDPRESS_API_URL!,
{
headers: {
'Content-Type': 'application/json',
},
}
);
Fetch all posts
// lib/wordpress.ts
import { client } from './graphql';
import { gql } from 'graphql-request';
export interface WPPost {
id: string;
slug: string;
title: string;
excerpt: string;
date: string;
featuredImage?: { node: { sourceUrl: string; altText: string } };
categories: { nodes: Array<{ name: string; slug: string }> };
}
export async function getAllPosts(): Promise<WPPost[]> {
const query = gql`
query GetAllPosts {
posts(first: 100, where: { status: PUBLISH }) {
nodes {
id
slug
title
excerpt
date
featuredImage {
node { sourceUrl altText }
}
categories {
nodes { name slug }
}
}
}
}
`;
const data = await client.request<{ posts: { nodes: WPPost[] } }>(query);
return data.posts.nodes;
}
Fetch a single post by slug
export async function getPostBySlug(slug: string) {
const query = gql`
query GetPost($slug: String!) {
postBy(slug: $slug) {
id
title
content
date
modified
featuredImage {
node { sourceUrl altText }
}
categories {
nodes { name slug }
}
seo {
title
metaDesc
opengraphImage { sourceUrl }
}
}
}
`;
const data = await client.request<{ postBy: WPPost & { content: string } }>(
query, { slug }
);
return data.postBy;
}
5. Building the Blog Pages
Blog listing page
// app/blog/page.tsx
import { getAllPosts } from '@/lib/wordpress';
import Image from 'next/image';
import Link from 'next/link';
export const revalidate = 3600; // ISR — revalidate every hour
export default async function BlogPage() {
const posts = await getAllPosts();
return (
<div>
<h1>Blog</h1>
<div className="grid grid-cols-3 gap-6">
{posts.map((post) => (
<article key={post.id}>
{post.featuredImage && (
<Image
src={post.featuredImage.node.sourceUrl}
alt={post.featuredImage.node.altText}
width={600}
height={400}
/>
)}
<h2>
<Link href={`/blog/${post.slug}`}>
<span dangerouslySetInnerHTML={{ __html: post.title }} />
</Link>
</h2>
<div dangerouslySetInnerHTML={{ __html: post.excerpt }} />
</article>
))}
</div>
</div>
);
}
Single post page with ISR
// app/blog/[slug]/page.tsx
import { getAllPosts, getPostBySlug } from '@/lib/wordpress';
import type { Metadata } from 'next';
export const revalidate = 3600;
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await getPostBySlug(params.slug);
return {
title: post.seo?.title || post.title,
description: post.seo?.metaDesc || post.excerpt,
};
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
return (
<article>
<h1 dangerouslySetInnerHTML={{ __html: post.title }} />
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
6. Handling WordPress Images
WordPress image URLs are absolute (pointing to your WordPress server). Configure Next.js to allow them:
// next.config.mjs
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'yoursite.com',
pathname: '/wp-content/uploads/**',
},
],
},
};
For best performance, use next/image — it automatically converts to WebP and applies lazy loading:
import Image from 'next/image';
<Image
src={post.featuredImage.node.sourceUrl}
alt={post.featuredImage.node.altText}
width={1200}
height={630}
priority // for above-the-fold images
/>
7. Menus and Navigation
WordPress menus aren't exposed by WPGraphQL by default. Install WPGraphQL for Menus:
query GetMenu {
menus(where: { location: PRIMARY }) {
nodes {
menuItems {
nodes {
id
label
url
parentId
}
}
}
}
}
8. On-Demand Revalidation (Webhooks)
Instead of waiting for ISR to expire, trigger cache revalidation when content is published in WordPress:
Next.js revalidation API route
// app/api/revalidate/route.ts
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
export async function POST(request: Request) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
if (secret !== process.env.WORDPRESS_PREVIEW_SECRET) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
}
const body = await request.json();
const slug = body?.post?.slug;
if (slug) {
revalidatePath(`/blog/${slug}`);
revalidatePath('/blog');
}
return NextResponse.json({ revalidated: true });
}
WordPress webhook
In WordPress, use the WP Webhooks plugin or a custom save_post action:
add_action('save_post', function ($post_id) {
$post = get_post($post_id);
if ($post->post_status !== 'publish') return;
wp_remote_post('https://your-nextjs-site.com/api/revalidate?secret=YOUR_SECRET', [
'body' => json_encode(['post' => ['slug' => $post->post_name]]),
'headers' => ['Content-Type' => 'application/json'],
]);
});
9. Preview Mode
To preview drafts before publishing:
// app/api/preview/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
if (secret !== process.env.WORDPRESS_PREVIEW_SECRET || !slug) {
return new Response('Invalid token', { status: 401 });
}
draftMode().enable();
redirect(`/blog/${slug}`);
}
Conclusion
Migrating to headless WordPress with Next.js gives you:
- Performance: Static generation + ISR = near-instant page loads
- DX: TypeScript, Tailwind, and modern tooling
- Flexibility: Multiple front-ends from one WordPress backend
- SEO: Full control over metadata, structured data, and rendering strategy
The migration path is clear:
- Install WPGraphQL on WordPress
- Build your Next.js front-end querying that GraphQL endpoint
- Use ISR for automatic cache updates, webhooks for on-demand revalidation
- Set your custom domain DNS to point to the new Next.js deployment
Your editors never have to touch anything — they keep working in WordPress while your users get a blazing-fast, modern experience.
Written by Dev Pattern LabsCore Engineering
Dev Pattern Labs is an engineering lab and digital consultancy specializing in custom Next.js web applications, WooCommerce REST API integrations, and workflow automation for growing businesses.
