Written by 5:41 pm APIs & Integrations, For Developers, How-To Guides Views: 0

How to Set Up Headless WordPress With Next.js in 2026 (Complete Tutorial)

Learn to set up headless WordPress with Next.js 15 App Router in 2026. Covers REST API, WPGraphQL, ISR, on-demand revalidation via webhook, draft preview mode, next/image for media, JWT authentication, and deployment to Vercel or Railway. Every step includes working code.

Headless WordPress with Next.js 2026 tutorial: architecture diagram showing WordPress CMS feeding Next.js App Router with ISR and Vercel deployment

Headless WordPress lets you keep the editorial tools your team already knows while serving content through a blazing-fast Next.js 15 frontend. Instead of WordPress rendering PHP templates, it acts as a pure content API. Next.js fetches posts, pages, and custom fields over REST or WPGraphQL, then builds static or server-rendered pages that score near-perfect on Core Web Vitals. This guide walks through the full setup: configuring WordPress as a headless CMS, building a Next.js 15 App Router project with fetch caching and ISR, wiring on-demand revalidation, enabling draft preview, handling images through next/image, adding JWT authentication, and deploying to Vercel or Railway. Every code block is copy-paste ready.

What Headless WordPress Actually Means

Traditional WordPress couples the backend (content management, database, PHP) with the frontend (theme templates, PHP rendering). Headless architecture breaks that coupling. WordPress handles only the backend: storing posts, managing users, running plugins that extend the data model. A separate JavaScript application, in this case Next.js, handles every pixel the visitor sees.

The connection between the two is an API. WordPress ships with a REST API built in and you can add WPGraphQL for a typed graph layer. Next.js calls those endpoints at build time, request time, or on a timed interval, then assembles HTML pages served over a global CDN.

The trade-off is real. You lose native WordPress previews, theme plugins, and some page builder integrations. You gain full control over the frontend stack, near-instant page loads, and the ability to deploy the front end independently of WordPress. For content-heavy sites, developer portfolios, and marketing sites with strict performance budgets, headless is worth the complexity.

When to Go Headless (And When Not To)

Headless makes sense when your site needs PageSpeed scores above 95 consistently, when your editorial team is comfortable in WordPress but your dev team wants React, when you need to serve the same content to a web app, a mobile app, and a public site, or when you want Vercel edge deployments with zero PHP on the critical path.

Headless gets painful when your site relies heavily on WooCommerce checkout (the cart state is hard to replicate), when you use many plugins that render shortcodes or inject HTML, when your team has no JavaScript experience, or when you need live previews of Gutenberg block layouts instantly.

If you are on the fence, read the full analysis in Headless WordPress with Next.js: Why and How to Go Decoupled before committing to the architecture.

Preparing WordPress as a Headless CMS

Step 1: Enable the REST API and CORS

The REST API is on by default in every modern WordPress installation. To confirm it works, open your browser and visit https://yourdomain.com/wp-json/wp/v2/posts. You should see a JSON array of posts.

Next.js will call this API from a different origin (your Vercel domain). Without CORS headers, the browser blocks those requests. Add this to your theme’s functions.php or a custom mu-plugin:

add_action( 'rest_api_init', function() {
    remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );
    add_filter( 'rest_pre_serve_request', function( $value ) {
        $origin = get_http_origin();
        $allowed = [
            'https://yoursite.vercel.app',
            'http://localhost:3000',
        ];
        if ( in_array( $origin, $allowed, true ) ) {
            header( 'Access-Control-Allow-Origin: ' . esc_url_raw( $origin ) );
            header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
            header( 'Access-Control-Allow-Credentials: true' );
        }
        return $value;
    } );
} );

For Server Components calling the API server-to-server (never from the browser), CORS is not needed. You only need CORS for client-side fetch calls in React Client Components.

Step 2: Install WPGraphQL (Optional but Recommended)

The REST API works fine for standard post, page, and taxonomy queries. WPGraphQL gives you a single typed endpoint where you request only the fields you need, which reduces payload size and makes complex nested queries much cleaner. Install the WPGraphQL plugin from the WordPress plugin directory. Once active, a GraphQL endpoint appears at https://yourdomain.com/graphql. Test it at the built-in IDE: https://yourdomain.com/wp-admin/admin.php?page=graphiql-ide.

A basic query to fetch posts with titles, slugs, and featured images:

query GetPosts {
  posts(first: 10) {
    nodes {
      id
      title
      slug
      date
      excerpt
      featuredImage {
        node {
          sourceUrl
          altText
          mediaDetails {
            width
            height
          }
        }
      }
    }
  }
}

Step 3: Create an Application Password for the Preview API

Draft preview requires authenticated API requests. Go to Users > Profile in your WordPress admin, scroll to the Application Passwords section, enter a name like “Next.js Preview”, and click Add New Application Password. Copy the password immediately because it is shown only once.

You will use this as a Basic Auth credential: Base64-encode username:application_password and send it in the Authorization header for draft preview requests.

Setting Up the Next.js 15 App Router Project

Step 4: Scaffold the Project

npx create-next-app@latest my-headless-wp --typescript --tailwind --app
cd my-headless-wp

The --app flag enables the App Router. This guide targets Next.js 15 with React 19 Server Components as the default. All fetch calls happen in Server Components unless you explicitly add "use client" at the top of a file.

Step 5: Configure Environment Variables

Create a .env.local file at the project root:

NEXT_PUBLIC_WP_URL=https://yourdomain.com
WP_AUTH_USER=your-wp-username
WP_AUTH_PASS=your-application-password
PREVIEW_SECRET=a-random-string-you-choose
REVALIDATE_SECRET=another-random-string-for-webhooks

Never commit .env.local to version control. Add it to .gitignore before your first push.

Step 6: Create a WordPress API Client

Create lib/wordpress.ts:

const WP_URL = process.env.NEXT_PUBLIC_WP_URL!;

export async function getAllPosts() {
  const res = await fetch(
    `${WP_URL}/wp-json/wp/v2/posts?_embed&per_page=100`,
    {
      next: { tags: ['posts'] },
    }
  );
  if (!res.ok) throw new Error('Failed to fetch posts');
  return res.json();
}

export async function getPostBySlug(slug: string) {
  const res = await fetch(
    `${WP_URL}/wp-json/wp/v2/posts?slug=${slug}&_embed`,
    {
      next: { tags: [`post-${slug}`] },
    }
  );
  if (!res.ok) throw new Error(`Failed to fetch post: ${slug}`);
  const posts = await res.json();
  return posts[0] ?? null;
}

export async function getDraftPostBySlug(slug: string) {
  const credentials = Buffer.from(
    `${process.env.WP_AUTH_USER}:${process.env.WP_AUTH_PASS}`
  ).toString('base64');
  const res = await fetch(
    `${WP_URL}/wp-json/wp/v2/posts?slug=${slug}&status=draft&_embed`,
    {
      headers: { Authorization: `Basic ${credentials}` },
      cache: 'no-store',
    }
  );
  if (!res.ok) throw new Error(`Draft fetch failed: ${slug}`);
  const posts = await res.json();
  return posts[0] ?? null;
}

The next: { tags: ['posts'] } option registers this fetch with Next.js’s on-demand revalidation system. When WordPress publishes a new post, a webhook fires and calls revalidateTag('posts') to bust exactly this cached response without a full rebuild.

Static Generation With ISR (Incremental Static Regeneration)

Step 7: Generate Post Pages at Build Time

Create app/posts/[slug]/page.tsx:

import { getAllPosts, getPostBySlug } from '@/lib/wordpress';
import { notFound } from 'next/navigation';

export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post: any) => ({ slug: post.slug }));
}

export const dynamicParams = true; // allow new slugs without full rebuild

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title.rendered}</h1>
      <div
        className="wp-content"
        dangerouslySetInnerHTML={{ __html: post.content.rendered }}
      />
    </article>
  );
}

Note: post.content.rendered is HTML generated by WordPress on its own server. It is not user-supplied raw input. Your WordPress installation already filters this through wp_kses_post on save. For an extra layer of safety on the Next.js side, consider running the string through a library like DOMPurify in a client component, or use a parser like html-react-parser with a transform function to convert HTML nodes into React elements without setting innerHTML at all.

With dynamicParams = true, pages not generated at build time are rendered on first request and then cached. This gives you the best of both worlds: fast static pages for existing content, on-demand rendering for new posts.

Step 8: Time-Based ISR as a Fallback

For pages where you want a guaranteed freshness window rather than relying on webhooks, add a revalidation interval directly on the page:

export const revalidate = 3600; // re-fetch from WordPress every hour

Place this export at the top of the page file. Next.js serves the cached version for up to one hour, then regenerates on the next request after that window expires. For a blog that publishes daily, this is often enough without needing webhooks at all.

On-Demand Revalidation Via Webhook

Time-based ISR has a latency window. If you publish a post and need it live within seconds, use on-demand revalidation instead.

Step 9: Create the Revalidation API Route

Create app/api/revalidate/route.ts:

import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const secret = request.nextUrl.searchParams.get('secret');
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
  }

  const body = await request.json();
  const slug = body?.post_name ?? '';

  revalidateTag('posts');
  if (slug) revalidateTag(`post-${slug}`);

  return NextResponse.json({
    revalidated: true,
    now: Date.now(),
    tags: ['posts', `post-${slug}`],
  });
}

Step 10: Set Up the WordPress Webhook

Add this to an mu-plugin to fire a POST request whenever a post is published or updated:

add_action( 'save_post', function( $post_id, $post, $update ) {
    if ( wp_is_post_revision( $post_id ) ) return;
    if ( $post->post_status !== 'publish' ) return;

    $endpoint = add_query_arg(
        'secret',
        getenv( 'REVALIDATE_SECRET' ),
        'https://yoursite.vercel.app/api/revalidate'
    );

    wp_remote_post( $endpoint, [
        'body'    => wp_json_encode( [
            'post_type' => $post->post_type,
            'post_name' => $post->post_name,
        ] ),
        'headers' => [ 'Content-Type' => 'application/json' ],
        'timeout' => 5,
        'blocking' => false,
    ] );
}, 10, 3 );

Set REVALIDATE_SECRET in your WordPress hosting environment variables so it matches the value in your Next.js project. With 'blocking' => false, WordPress fires the webhook and continues without waiting for a response, keeping your admin fast.

Draft Preview Mode

Step 11: Enable Next.js Draft Mode

Create app/api/preview/route.ts:

import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';

export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl;
  const secret = searchParams.get('secret');
  const slug = searchParams.get('slug');

  if (secret !== process.env.PREVIEW_SECRET || !slug) {
    return new Response('Invalid token', { status: 401 });
  }

  const dm = await draftMode();
  dm.enable();
  redirect(`/posts/${slug}`);
}

Update your post page to serve draft content when Draft Mode is active:

import { draftMode } from 'next/headers';
import { getDraftPostBySlug, getPostBySlug } from '@/lib/wordpress';

export default async function PostPage({ params }) {
  const { isEnabled } = await draftMode();
  const { slug } = await params;
  const post = isEnabled
    ? await getDraftPostBySlug(slug)
    : await getPostBySlug(slug);

  if (!post) notFound();
  // render...
}

In WordPress, bookmark the preview URL pattern: https://yoursite.vercel.app/api/preview?secret=PREVIEW_SECRET&slug=POST_SLUG. Editors paste the post slug and open that link to see the draft rendered exactly as the live site will look.

Image Handling With next/image and WordPress Media

Step 12: Configure Remote Image Patterns

WordPress stores images at your domain. To use next/image for automatic WebP conversion, resizing, and lazy loading, declare your WordPress domain in next.config.ts:

import type { NextConfig } from 'next';

const config: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'yourdomain.com',
        pathname: '/wp-content/uploads/**',
      },
    ],
  },
};

export default config;

Step 13: Render Featured Images

WordPress REST API returns the featured image as a nested _embedded object when you add ?_embed to the request URL:

import Image from 'next/image';

function FeaturedImage({ post }: { post: any }) {
  const img = post._embedded?.['wp:featuredmedia']?.[0];
  if (!img) return null;

  return (
    <Image
      src={img.source_url}
      alt={img.alt_text || ''}
      width={img.media_details?.width ?? 1200}
      height={img.media_details?.height ?? 630}
      priority
    />
  );
}

Using next/image instead of a plain img tag gives you automatic WebP conversion, responsive sizes, and built-in lazy loading that keeps your Largest Contentful Paint score low.

JWT Authentication for Protected Content

Step 14: Install the JWT Auth Plugin

For content behind login (membership sites, gated tutorials), use JWT authentication rather than Application Passwords. Install the JWT Authentication for WP REST API plugin, then add this to wp-config.php:

define( 'JWT_AUTH_SECRET_KEY', 'your-very-strong-secret-key' );
define( 'JWT_AUTH_CORS_ENABLE', true );

Add the authorization header support to your .htaccess:

SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1

Step 15: Request a Token From Next.js

export async function getJwtToken(username: string, password: string) {
  const res = await fetch(
    `${process.env.NEXT_PUBLIC_WP_URL}/wp-json/jwt-auth/v1/token`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username, password }),
      cache: 'no-store',
    }
  );
  if (!res.ok) throw new Error('Authentication failed');
  const data = await res.json();
  return data.token as string;
}

Store the token in an HTTP-only cookie using a Next.js Route Handler, never in localStorage. Use the token as Authorization: Bearer {token} on subsequent requests to protected endpoints.

Deploying to Vercel

Step 16: Push to GitHub and Connect Vercel

Vercel is the easiest deploy target for Next.js and has native support for ISR and on-demand revalidation. Push your project to GitHub, go to vercel.com, and import the repository. Add environment variables in the Vercel dashboard: NEXT_PUBLIC_WP_URL, WP_AUTH_USER, WP_AUTH_PASS, PREVIEW_SECRET, and REVALIDATE_SECRET. Vercel automatically detects the Next.js App Router and configures the correct build output. ISR pages are cached at the Edge; on-demand revalidation via revalidateTag purges those Edge caches globally within seconds.

Deploying to Railway (Alternative)

If you prefer Railway for simpler pricing or to avoid vendor lock-in, add a Dockerfile at the project root:

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

Enable output: 'standalone' in next.config.ts, push to a Railway project, and set the same environment variables. Railway does not have a global edge network, so for wide geographic distribution add a Cloudflare proxy in front to cache static responses at the edge.

SEO Considerations for Headless WordPress

Standard WordPress SEO plugins like RankMath and Yoast write meta tags into PHP templates that Next.js never renders. You need to pull that metadata through the API and inject it using the Next.js generateMetadata function.

Yoast exposes a yoast_head_json object directly on each post in the REST response when you request ?_fields=yoast_head_json,title,excerpt:

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
  if (!post) return {};

  return {
    title: post.yoast_head_json?.title ?? post.title.rendered,
    description: post.yoast_head_json?.description ?? post.excerpt.rendered,
    openGraph: {
      images: post.yoast_head_json?.og_image?.[0]?.url
        ? [post.yoast_head_json.og_image[0].url]
        : [],
    },
  };
}

This approach gives you all the SEO metadata your editors set in WordPress without duplicating effort in Next.js. Canonical URLs, Open Graph images, and JSON-LD structured data all flow through automatically.

Common Pitfalls and How to Avoid Them

Shortcodes and some Gutenberg blocks render as raw markup. When WordPress content arrives via the REST API, content.rendered is HTML but blocks that rely on JavaScript enqueued by WordPress (like Contact Form 7 or some slider blocks) will not work. Audit your content types before going headless. Plain Gutenberg text, image, and heading blocks work fine.

Menus are not exposed by the REST API by default. Install the WP REST API Menus plugin or use WPGraphQL’s menus query. For simpler setups, hard-code the navigation in Next.js or store it in a JSON config file.

Pagination on archive pages. WordPress paginates REST API responses with X-WP-TotalPages and X-WP-Total headers. Read those headers from the fetch response to build paginated archives with correct total page counts.

Slow builds on large sites. If you have thousands of posts, generateStaticParams fetching all posts at build time creates a slow CI pipeline. Fetch only the most recent 100 posts and rely on dynamicParams = true for older content. The first visitor to an older page gets a slightly slower response; every visitor after gets a cached page.

Webhook delivery failures. The WordPress wp_remote_post call with 'blocking' => false means failures are silent. Add revalidate = 3600 on your post pages as a fallback so stale content is never more than one hour old even if a webhook drops.

Testing Your Headless Setup Locally

You do not need a public WordPress site to develop locally. Use Local by Flywheel or a Docker-based stack to run WordPress on http://wordpress.test, then point your Next.js NEXT_PUBLIC_WP_URL at that local address. Webhooks will not reach your local dev server, but you can simulate them directly:

curl -X POST "http://localhost:3000/api/revalidate?secret=YOUR_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"post_name":"test-post"}'

For a deeper look at how WordPress caches database queries internally, which directly affects API response speed under load, the guide on the WordPress Transients API covers transient caching patterns that keep your headless backend fast.

Extending the Architecture: Custom Post Types and Advanced Queries

Once your basic post and page routes work, expanding the architecture to custom post types is straightforward. If you have a “Projects” CPT registered in WordPress, it is available at /wp-json/wp/v2/projects automatically. Add a getProjectBySlug function to your WordPress API client following the same pattern as getPostBySlug, then create an app/projects/[slug]/page.tsx file mirroring your post page structure.

For taxonomy archive pages (category listings, tag pages), the REST API exposes categories at /wp-json/wp/v2/categories and tags at /wp-json/wp/v2/tags. Filter posts by category ID with ?categories=42 or by slug by first fetching the category ID, then filtering. With WPGraphQL, taxonomy queries are cleaner:

query PostsByCategory($slug: String!) {
  category(id: $slug, idType: SLUG) {
    name
    posts {
      nodes {
        title
        slug
        excerpt
        date
      }
    }
  }
}

For search functionality, the REST API accepts a ?search=keyword parameter. Because this is a dynamic query (different for every user), mark the search page as dynamic = 'force-dynamic' in Next.js to skip static generation entirely and always fetch fresh results. Pair it with a Client Component input field that calls a Route Handler, which in turn calls the WordPress search endpoint. This keeps your search credentials server-side while giving users a responsive search experience.

For ACF (Advanced Custom Fields) data, install the ACF to REST API plugin. This exposes your custom fields under an acf key on each post object in the REST response. With WPGraphQL, install the WPGraphQL for ACF extension and query custom fields directly in your GraphQL schema. Either approach means your editorial team can add structured data in WordPress and your Next.js templates can consume it with full type safety when you add TypeScript interfaces.

Monitoring and Maintaining a Headless WordPress Site

A headless setup has two systems to monitor instead of one. On the WordPress side, watch your API response times. If your /wp-json/wp/v2/posts endpoint starts taking more than 500ms, your database is likely under load. Review autoloaded options in wp_options, add an object cache layer (Redis or Memcached), or increase PHP-FPM workers. A slow WordPress API directly delays your Next.js build times and increases Time to First Byte for dynamically rendered pages.

On the Next.js side, monitor your Vercel Analytics or Railway logs for cache hit rates on ISR pages. A very low cache hit rate on popular pages suggests your revalidation webhooks are firing too often or your revalidate interval is too short. Conversely, if editors complain that published content takes too long to appear, check your webhook delivery logs (WordPress stores nothing by default, so add a custom logging action) and verify the revalidation route is returning a 200 status.

Set up uptime monitoring for both the WordPress admin URL and the Next.js production URL independently. If WordPress goes down, your statically cached pages on Vercel continue serving visitors without interruption. That is one of the key reliability benefits of headless architecture: the public-facing site keeps working even during WordPress maintenance windows.

Putting It All Together

The architecture you have built through this guide works like this. WordPress runs as a backend-only CMS: it stores content, media, and user accounts, exposes the REST API and optionally GraphQL, and fires a webhook on publish. Next.js 15 with the App Router handles the frontend: Server Components fetch from WordPress at build time with tag-based cache registration, pages are statically generated and served from CDN, and Draft Mode bypasses the cache for authenticated preview sessions. Vercel (or Railway with a Cloudflare proxy) runs the Next.js build, caches ISR pages at the edge, and exposes the revalidation API route. On-demand revalidation purges the edge cache within seconds of a WordPress publish event.

The result is a site that loads in under a second for most visitors, lets your editors work in the WordPress admin they already know, and gives your development team full control over every HTML element, component, and CSS rule. Start with a single post archive and single post page. Get those two routes working end-to-end with ISR and preview, then expand to pages, categories, and custom post types. The architecture scales cleanly as you add more routes and content types.

Visited 1 times, 1 visit(s) today

Last modified: May 8, 2026

Close