Sheraz AhmadSenior Web Developer
Let's talk

Headless WordPress Next.js App Router: Complete Setup Guide

An engineering-focused, end-to-end guide to coupling WordPress with the Next.js App Router. Master dynamic routing, WPGraphQL, ISR, and preview environments.

Quick answer

For those needing to deploy a headless WordPress Next.js App Router architecture immediately, verify these core integration requirements:

  • GraphQL over REST: Install the WPGraphQL plugin in WordPress. It reduces payload sizes and avoids the multiple-round-trip waterfall issues inherent to the WP REST API.
  • Dynamic Routing: Use a Next.js optional catch-all route (app/[[...paths]]/page.tsx) to resolve WordPress URIs dynamically via a single GraphQL query.
  • Data Fetching & ISR: Use standard fetch() in Server Components with next: { revalidate: 300 } to implement Incremental Static Regeneration (ISR).
  • ACF Integration: Install WPGraphQL for Advanced Custom Fields. Ensure "Show in GraphQL" is enabled on your field groups.
  • Draft Previews: Use Next.js Draft Mode in combination with a secure WordPress preview endpoint to bypass static generation caches for authenticated editors.

---

Why choose WPGraphQL over the REST API?

When building a headless WordPress Next.js App Router site, you must choose how your frontend communicates with your backend. While the native WP REST API is built-in, WPGraphQL is the superior choice for production applications.

The REST API suffers from over-fetching and under-fetching. To render a single blog post with its author profile, featured image, categories, and ACF fields, the REST API requires multiple HTTP requests or complex custom endpoint registration. This creates network bottlenecks and slows down your Next.js build times.

WPGraphQL solves this by allowing the App Router to request exactly what it needs in a single query. It maps your WordPress database schema into a strongly-typed graph. When combined with TypeScript, this ensures your frontend data contracts remain stable.

To prepare your WordPress instance, install and activate: 1. WPGraphQL: The core GraphQL schema for WordPress. 2. WPGraphQL for Advanced Custom Fields: Maps your ACF fields directly into the GraphQL schema.

In your ACF Field Group settings, you must toggle "Show in GraphQL" to active and provide a "GraphQL Field Name" (use camelCase, such as pageBuilder or heroSection).

---

Configuring the Next.js App Router for WordPress data

Next.js Server Components run on the server by default. This allows you to fetch data directly inside your component file without client-side JS overhead.

First, set up your environment variables in .env.local:

NEXT_PUBLIC_WORDPRESS_API_URL=https://your-wordpress-backend.local/graphql
WORDPRESS_PREVIEW_SECRET=your_secure_random_string_here

Next, create a utility function to handle GraphQL requests. This helper handles error boundaries and ensures TypeScript types are respected.

// lib/api.ts
interface FetchAPIResponse {
  data?: any;
  errors?: Array<{ message: string }>;
}

export async function fetchAPI(
  query: string,
  { variables }: { variables?: Record<string, any> } = {}
) {
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };

  if (process.env.WORDPRESS_AUTH_REFRESH_TOKEN) {
    headers['Authorization'] = `Bearer ${process.env.WORDPRESS_AUTH_REFRESH_TOKEN}`;
  }

  const res = await fetch(process.env.NEXT_PUBLIC_WORDPRESS_API_URL as string, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      query,
      variables,
    }),
    next: {
      revalidate: 600, // Cache data for 10 minutes (ISR)
      tags: ['wordpress'],
    },
  });

  const json: FetchAPIResponse = await res.json();

  if (json.errors) {
    console.error(json.errors);
    throw new Error('Failed to fetch API from WordPress');
  }

  return json.data;
}

This helper uses the native fetch API, which Next.js extends. The next.revalidate property sets the default ISR window, while next.tags allows us to trigger on-demand revalidation when content changes in WordPress.

---

Implementing dynamic routing for pages and posts

WordPress uses a hierarchical URI structure (e.g., /parent-page/child-page/ or /blog/category/post-name/). To mirror this structure in the Next.js App Router, use an optional catch-all route: app/[[...paths]]/page.tsx.

This single route file intercepts all incoming requests, queries WPGraphQL to see if a matching URI exists in WordPress, and renders the appropriate template.

// app/[[...paths]]/page.tsx
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { fetchAPI } from '@/lib/api';

interface PageProps {
  params: Promise<{ paths?: string[] }>;
}

async function getPageByUri(uri: string) {
  const query = `
    query GetContentByUri($uri: String!) {
      nodeByUri(uri: $uri) {
        __typename
        ... on Page {
          id
          title
          content
        }
        ... on Post {
          id
          title
          content
          date
        }
      }
    }
  `;
  const data = await fetchAPI(query, { variables: { uri } });
  return data?.nodeByUri;
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const resolvedParams = await params;
  const uri = resolvedParams.paths ? `/${resolvedParams.paths.join('/')}/` : '/';
  const node = await getPageByUri(uri);

  if (!node) return {};

  return {
    title: `${node.title} - Headless Site`,
  };
}

export default async function Page({ params }: PageProps) {
  const resolvedParams = await params;
  const uri = resolvedParams.paths ? `/${resolvedParams.paths.join('/')}/` : '/';
  const node = await getPageByUri(uri);

  if (!node) {
    notFound();
  }

  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold mb-4">{node.title}</h1>
      {node.__typename === 'Post' && (
        <p className="text-gray-500 mb-4">Published on {new Date(node.date).toLocaleDateString()}</p>
      )}
      <div 
        className="prose max-w-none"
        dangerouslySetInnerHTML={{ __html: node.content }} 
      />
    </main>
  );
}

This setup handles homepages (/), standard pages (/about/), and deep nested posts (/blog/news/my-first-post/) inside a single file. It inspects the __typename returned by WPGraphQL to determine what kind of content to render.

Gotcha: WordPress URIs always have leading and trailing slashes (e.g., /about-us/). Next.js path parameters do not include these. You must format the path array into a clean WordPress URI format (adding slashes) before sending it to WPGraphQL, otherwise the query will return null.

---

Setting up Incremental Static Regeneration (ISR)

To keep your build fast and your server costs low, pre-render your most popular pages at build time using generateStaticParams. Pages not generated during the build will be built on-demand when first requested, then cached.

Add this function to your app/[[...paths]]/page.tsx file:

export async function generateStaticParams() {
  const query = `
    query GetAllContentUris {
      pages(first: 100) {
        nodes {
          uri
        }
      }
    }
  `;
  
  try {
    const data = await fetchAPI(query);
    const paths = data?.pages?.nodes
      ?.map((node: { uri: string }) => {
        const cleanUri = node.uri.replace(/^\/|\/$/g, '');
        if (cleanUri === '') return null;
        return { paths: cleanUri.split('/') };
      })
      .filter(Boolean) || [];

    return paths;
  } catch (error) {
    console.error('Error generating static params:', error);
    return [];
  }
}

This fetches the first 100 pages from WordPress during next build and generates static HTML files. For any page outside this initial list, Next.js will fall back to SSR on the first visit, generate the static cache in the background, and serve the cached version to all subsequent visitors.

---

Handling draft previews securely in Next.js

In a headless setup, the default WordPress "Preview" button breaks because it points to the backend URL, not your Next.js application. To fix this, we must use Next.js Draft Mode.

First, register a custom endpoint in WordPress using a simple PHP utility. This script redirects the editor securely to your Next.js frontend with a preview token.

<?php
// Place in your custom plugin or theme's functions.php
add_action('init', 'register_headless_preview_endpoint');

function register_headless_preview_endpoint() {
    add_rewrite_rule('^headless-preview/?{{BODY}}#39;, 'index.php?headless_preview=1', 'top');
}

add_filter('query_vars', function($vars) {
    $vars[] = 'headless_preview';
    return $vars;
});

add_action('template_redirect', function() {
    if (get_query_var('headless_preview')) {
        $post_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
        $secret  = isset($_GET['secret']) ? sanitize_text_field($_GET['secret']) : '';
        
        if ($secret !== 'your_secure_random_string_here') {
            wp_die('Unauthorized preview request.');
        }

        $post = get_post($post_id);
        if (!$post) {
            wp_die('Post not found.');
        }

        $slug = get_page_uri($post);
        $frontend_url = 'https://your-nextjs-frontend.com/api/preview';
        
        $redirect_url = add_query_arg([
            'secret' => $secret,
            'id' => $post_id,
            'slug' => $slug
        ], $frontend_url);

        wp_redirect($redirect_url);
        exit;
    }
});

Next, create the API route in your Next.js App Router to handle this incoming redirect, enable Draft Mode, and set the temporary cookies.

// 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 id = searchParams.get('id');
  const slug = searchParams.get('slug');

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

  const draft = await draftMode();
  draft.enable();

  // Redirect to the path we want to preview
  redirect(`/${slug}?preview=true&id=${id}`);
}

When Draft Mode is enabled, Next.js bypasses the static cache for all fetch requests. In your fetchAPI helper, you can check if draftMode().isEnabled is true, and if so, append the required preview headers or modify the query to fetch the draft revision instead of the published post.

---

Checklist

  • Install WPGraphQL and WPGraphQL for ACF on your WordPress backend.
  • Enable "Show in GraphQL" on all ACF Field Groups you need to access.
  • Configure .env.local with your WordPress API URL and preview secret.
  • Create a robust fetchAPI utility handling errors and cache variables.
  • Set up app/[[...paths]]/page.tsx as an optional catch-all route.
  • Implement generateStaticParams to build critical pages statically.
  • Implement the WordPress redirect hook and Next.js Draft Mode API route for previews.
Reference documentation

Drafted with AI assistance. Code targets current WordPress, WooCommerce and Next.js APIs; test changes on a staging site before production.

Need a fast, modern website?

I'm Sheraz Ahmad, a senior WordPress and headless Next.js developer with 7+ years of shipping high-performance sites. Open to permanent roles, contracts and freelance projects.

Let's talk