Dev Pattern Labs logo
Dev Pattern Labs Blog
WordPress 7 min readOct 21, 2026

Migrating from WordPress to Next.js Headless: Step-by-Step Guide

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.

Dev Pattern Labs Author

Dev Pattern Labs

Peer-reviewed technical tutorial · Production tested

WordPress dashboard next to a Next.js code editor
Executive Summary & Key Insight

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.

Advertisement

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 cachingSmall blogs with simple needs
Custom front-end design beyond themesHeavy 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 ReactBudget 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:

  1. Install the WPGraphQL plugin from the WordPress plugin directory
  2. Activate it — a new GraphQL menu appears in your admin
  3. 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:

  1. Install WPGraphQL on WordPress
  2. Build your Next.js front-end querying that GraphQL endpoint
  3. Use ISR for automatic cache updates, webhooks for on-demand revalidation
  4. 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.

Tags:#WordPress#Next.js#headless CMS#WPGraphQL#migration
Dev Pattern Labs Engineering Team

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.

Advertisement