Type something to search...
How to use WordPress with Next.js as a headless CMS?

How to use WordPress with Next.js as a headless CMS?

To use WordPress with Next.js as a headless CMS, you keep WordPress as the place where content is written and managed, then build a Next.js App Router project that fetches posts and pages from the WordPress REST API (or WPGraphQL) inside Server Components. Next.js renders and caches the pages, and WordPress tells Next.js when content changes so it can refresh them. Editors keep the familiar dashboard, and visitors get a fast, modern front end.

In this guide you will build a working headless blog step by step. You will prepare WordPress, create a Next.js project, write a small typed API client, build a posts listing and dynamic post pages, handle images and SEO metadata, and set up on-demand revalidation so new content appears without a full rebuild. The examples use the Next.js App Router and TypeScript.

Why Combine WordPress and Next.js?

WordPress offers one of the most mature editing experiences available: the block editor, media library, user roles, revisions, and a huge plugin ecosystem. Next.js offers a modern React front end with server rendering, static generation, image optimization, and fine-grained caching.

Together they give you:

  • A familiar CMS for editors: No retraining required.
  • Fast pages: Pre-rendered and cached HTML served from a CDN.
  • Front-end freedom: React components, TypeScript, and any styling approach you like.
  • Separation of concerns: The WordPress admin can live on a separate, locked-down domain.

If you are new to the concept, the key idea is that WordPress stops rendering the public site and becomes a content API.

Architecture Overview

Here is how the pieces fit together:

  1. WordPress runs at a domain like cms.example.com and exposes content at /wp-json/wp/v2/.
  2. Next.js runs at www.example.com, fetches content on the server, and renders HTML.
  3. Caching: Next.js caches fetched data and rendered pages, tagged so they can be refreshed selectively.
  4. Revalidation webhook: When an editor publishes or updates a post, WordPress calls a Next.js Route Handler, which invalidates the relevant cache tags.

Step 1: Prepare WordPress

Your WordPress site needs a few things in place.

  1. Pretty permalinks: Go to Settings > Permalinks and choose any option other than "Plain" so that /wp-json/ URLs work.
  2. Check the API: Visit https://cms.example.com/wp-json/wp/v2/posts and confirm you see JSON.
  3. Expose custom content: Any custom post types you need should be registered with show_in_rest set to true.
  4. Optional plugins: Install WPGraphQL if you prefer GraphQL, and Advanced Custom Fields if you need structured fields. This guide uses the built-in REST API, so neither is required.

Add a Revalidation Secret

Add a shared secret to wp-config.php, above the line that says "That's all, stop editing!":

define( 'NEXTJS_REVALIDATE_URL', 'https://www.example.com/api/revalidate' );
define( 'NEXTJS_REVALIDATE_SECRET', 'replace-with-a-long-random-string' );

You will use these later to notify Next.js when content changes.

Step 2: Create the Next.js Project

Create a new project with the official CLI:

npx create-next-app@latest wp-headless
cd wp-headless

Accept the defaults for TypeScript, ESLint, Tailwind CSS, and the App Router, or choose what suits you. Then create a .env.local file in the project root:

WORDPRESS_API_URL=https://cms.example.com/wp-json/wp/v2
REVALIDATE_SECRET=replace-with-a-long-random-string

The secret must match NEXTJS_REVALIDATE_SECRET in WordPress. Variables without the NEXT_PUBLIC_ prefix are only available on the server, which is exactly what you want for secrets.

Start the development server:

npm run dev

Step 3: Write a Typed WordPress API Client

Create lib/wordpress.ts. Centralizing all requests in one file makes caching and error handling consistent.

const API_URL = process.env.WORDPRESS_API_URL;

if (!API_URL) {
  throw new Error("WORDPRESS_API_URL is not defined");
}

export type WPPost = {
  id: number;
  slug: string;
  date: string;
  modified: string;
  title: { rendered: string };
  excerpt: { rendered: string };
  content: { rendered: string };
  _embedded?: {
    "wp:featuredmedia"?: Array<{
      source_url: string;
      alt_text: string;
      media_details?: { width: number; height: number };
    }>;
    author?: Array<{ name: string }>;
  };
};

type FetchOptions = {
  tags?: string[];
  revalidate?: number | false;
};

async function wpFetch<T>(
  path: string,
  options: FetchOptions = {},
): Promise<T> {
  const res = await fetch(`${API_URL}${path}`, {
    next: {
      revalidate: options.revalidate ?? 3600,
      tags: options.tags ?? ["wordpress"],
    },
  });

  if (!res.ok) {
    throw new Error(
      `WordPress request failed: ${res.status} ${res.statusText} for ${path}`,
    );
  }

  return res.json() as Promise<T>;
}

export async function getPosts(perPage = 10): Promise<WPPost[]> {
  return wpFetch<WPPost[]>(
    `/posts?per_page=${perPage}&_embed=wp:featuredmedia,author`,
    { tags: ["wordpress", "posts"] },
  );
}

export async function getPostBySlug(slug: string): Promise<WPPost | null> {
  const posts = await wpFetch<WPPost[]>(
    `/posts?slug=${encodeURIComponent(slug)}&_embed=wp:featuredmedia,author`,
    { tags: ["wordpress", "posts", `post:${slug}`] },
  );

  return posts[0] ?? null;
}

export async function getAllPostSlugs(): Promise<string[]> {
  const posts = await wpFetch<Array<{ slug: string }>>(
    "/posts?per_page=100&_fields=slug",
    { tags: ["wordpress", "posts"] },
  );

  return posts.map((post) => post.slug);
}

A few details worth noting:

  • next.revalidate caches each response for an hour as a safety net, even if the webhook fails.
  • next.tags labels cached data so you can invalidate it precisely later.
  • _embed pulls in the featured image and author in the same request.
  • _fields keeps the slug request tiny.

For sites with more than 100 posts, loop through pages using the X-WP-TotalPages response header.

Step 4: Build the Blog Listing Page

Create app/blog/page.tsx. In the App Router, pages are Server Components by default, so you can await data directly.

import Link from "next/link";
import { getPosts } from "@/lib/wordpress";

export const metadata = {
  title: "Blog",
  description: "Latest articles from our WordPress-powered blog.",
};

export default async function BlogPage() {
  const posts = await getPosts(12);

  return (
    <main className="mx-auto max-w-3xl px-4 py-12">
      <h1 className="mb-8 text-4xl font-bold">Blog</h1>
      <ul className="space-y-8">
        {posts.map((post) => (
          <li key={post.id}>
            <Link
              href={`/blog/${post.slug}`}
              className="text-2xl font-semibold hover:underline"
            >
              <span dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
            </Link>
            <time
              className="mt-1 block text-sm text-gray-500"
              dateTime={post.date}
            >
              {new Date(post.date).toLocaleDateString("en-US", {
                year: "numeric",
                month: "long",
                day: "numeric",
              })}
            </time>
            <div
              className="mt-2 text-gray-700"
              dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }}
            />
          </li>
        ))}
      </ul>
    </main>
  );
}

WordPress returns titles and excerpts as HTML (with encoded entities like &#8217;), which is why they are rendered with dangerouslySetInnerHTML. This is acceptable because the content comes from your own trusted CMS. Never do this with untrusted user input.

Step 5: Build Dynamic Post Pages

Create app/blog/[slug]/page.tsx. In current versions of Next.js, params is a Promise, so you must await it.

import Image from "next/image";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { getAllPostSlugs, getPostBySlug } from "@/lib/wordpress";

type Props = {
  params: Promise<{ slug: string }>;
};

export async function generateStaticParams() {
  const slugs = await getAllPostSlugs();
  return slugs.map((slug) => ({ slug }));
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPostBySlug(slug);

  if (!post) {
    return {};
  }

  const description = post.excerpt.rendered
    .replace(/<[^>]+>/g, "")
    .trim()
    .slice(0, 160);
  const image = post._embedded?.["wp:featuredmedia"]?.[0]?.source_url;

  return {
    title: post.title.rendered.replace(/<[^>]+>/g, ""),
    description,
    alternates: { canonical: `/blog/${slug}` },
    openGraph: {
      type: "article",
      publishedTime: post.date,
      modifiedTime: post.modified,
      images: image ? [image] : [],
    },
  };
}

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);

  if (!post) {
    notFound();
  }

  const featured = post._embedded?.["wp:featuredmedia"]?.[0];
  const author = post._embedded?.author?.[0]?.name;

  return (
    <article className="mx-auto max-w-3xl px-4 py-12">
      <h1
        className="mb-4 text-4xl font-bold"
        dangerouslySetInnerHTML={{ __html: post.title.rendered }}
      />
      <p className="mb-8 text-sm text-gray-500">
        {author ? `By ${author} · ` : ""}
        <time dateTime={post.date}>
          {new Date(post.date).toLocaleDateString("en-US")}
        </time>
      </p>

      {featured && (
        <Image
          src={featured.source_url}
          alt={featured.alt_text || ""}
          width={featured.media_details?.width ?? 1200}
          height={featured.media_details?.height ?? 630}
          className="mb-8 h-auto w-full rounded-lg"
          priority
        />
      )}

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

generateStaticParams() pre-renders every known post at build time. New posts that did not exist during the build are rendered on first request and then cached, because dynamic params are allowed by default.

Step 6: Configure Remote Images

next/image only optimizes images from hosts you explicitly allow. Update next.config.ts:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "cms.example.com",
        pathname: "/wp-content/uploads/**",
      },
    ],
  },
};

export default nextConfig;

Restart the dev server after changing the config. Without this, you will see an error saying the hostname is not configured.

Step 7: Style WordPress Block Content

Post content arrives as HTML generated by the block editor, with class names like wp-block-image, wp-block-quote, and has-text-align-center. You have three options for styling it:

  1. Write your own styles: Target the WordPress classes inside a wrapper, as shown below.
  2. Use Tailwind Typography: The @tailwindcss/typography plugin's prose class styles headings, lists, and paragraphs nicely.
  3. Import core block styles: The @wordpress/block-library npm package includes the CSS WordPress uses for core blocks.

A small starting point in app/globals.css:

.wp-content > * + * {
  margin-top: 1.25rem;
}

.wp-content h2 {
  font-size: 1.75rem;
  font-weight: 700;
  margin-top: 2.5rem;
}

.wp-content a {
  color: #2563eb;
  text-decoration: underline;
}

.wp-content .wp-block-image img {
  max-width: 100%;
  height: auto;
  border-radius: 0.5rem;
}

.wp-content .wp-block-quote {
  border-left: 4px solid #e5e7eb;
  padding-left: 1rem;
  font-style: italic;
}

.wp-content .has-text-align-center {
  text-align: center;
}

Rewriting Internal Links

Links inside post content point to your WordPress domain. You can rewrite them to your Next.js domain with a simple replacement before rendering:

export function rewriteWpLinks(html: string): string {
  return html.replaceAll(
    "https://cms.example.com/",
    "https://www.example.com/",
  );
}

Be careful not to rewrite links to /wp-content/uploads/, which should still point to WordPress (or your media CDN). A more robust approach parses the HTML and only rewrites links that match your post URL structure.

Step 8: Set Up On-Demand Revalidation

Time-based revalidation works, but updates can take up to an hour to appear. On-demand revalidation refreshes content almost immediately after an editor clicks Update.

The Next.js Route Handler

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.headers.get("x-revalidate-secret");

  if (!secret || secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ message: "Invalid secret" }, { status: 401 });
  }

  const body = (await request.json().catch(() => ({}))) as { slug?: string };

  revalidateTag("posts", "max");

  if (body.slug) {
    revalidateTag(`post:${body.slug}`, "max");
  }

  return NextResponse.json({ revalidated: true, slug: body.slug ?? null });
}

In current versions of Next.js, revalidateTag() takes a second argument. Passing 'max' marks the tagged data as stale, so the next visitor triggers a background refresh while still receiving a fast response.

The WordPress Webhook

In WordPress, call this route whenever a post is published, updated, or trashed. Put the code in a small custom plugin so it does not depend on the theme:

<?php
/**
 * Plugin Name: Next.js Revalidation
 * Description: Notifies the Next.js front end when posts change.
 * Version:     1.0.0
 */

if ( ! defined( 'ABSPATH' ) ) {
exit;
}

function sajjad_nextjs_revalidate( $post_id ) {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
  return;
}

if ( ! defined( 'NEXTJS_REVALIDATE_URL' ) || ! defined( 'NEXTJS_REVALIDATE_SECRET' ) ) {
  return;
}

$post = get_post( $post_id );

if ( ! $post || 'post' !== $post->post_type ) {
  return;
}

wp_remote_post(
NEXTJS_REVALIDATE_URL,
array(
  'timeout'  => 5,
  'blocking' => false,
  'headers'  => array(
    'Content-Type'        => 'application/json',
    'x-revalidate-secret' => NEXTJS_REVALIDATE_SECRET,
  ),
  'body'     => wp_json_encode(
    array( 'slug' => $post->post_name )
  ),
)
);
}
add_action( 'save_post', 'sajjad_nextjs_revalidate' );
add_action( 'trashed_post', 'sajjad_nextjs_revalidate' );

Test it by updating a post and reloading the corresponding page on your Next.js site. The first reload may still show cached content while the refresh happens in the background; the next one shows the new version.

Handling Previews and Drafts

By default, the REST API only returns published posts to unauthenticated requests. To preview drafts, you need:

  1. Authentication: Create an Application Password for a WordPress user under Users > Profile, and store the credentials as server-only environment variables in Next.js.
  2. A preview route: A Route Handler that checks a secret, enables Next.js Draft Mode with draftMode(), and redirects to the post.
  3. Conditional fetching: When Draft Mode is on, fetch the post with authentication and status=draft (or by ID through /posts/<id>), bypassing the cache.
  4. A preview link in WordPress: Use the preview_post_link filter to point the Preview button at your Next.js preview route.

Frameworks such as Faust.js handle much of this for you if you prefer not to build it from scratch.

SEO Considerations

Search engines see only what Next.js renders, so you are responsible for SEO output:

  • Metadata: Use generateMetadata() as shown above. If you use Yoast SEO, its REST API exposes a yoast_head_json field on posts that contains titles, descriptions, and Open Graph data you can map into Next.js metadata.
  • Sitemap: Create app/sitemap.ts that fetches all slugs and returns a sitemap array.
  • Robots: Add app/robots.ts, and make sure your WordPress CMS domain is set to discourage indexing under Settings > Reading so search engines do not index duplicate content.
  • Redirects: Recreate important WordPress redirects in next.config.ts or in your Route Handlers.
  • Structured data: Output JSON-LD for articles in your post template.

A simple sitemap example:

import type { MetadataRoute } from "next";
import { getAllPostSlugs } from "@/lib/wordpress";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const slugs = await getAllPostSlugs();

  return [
    { url: "https://www.example.com/", lastModified: new Date() },
    { url: "https://www.example.com/blog", lastModified: new Date() },
    ...slugs.map((slug) => ({
      url: `https://www.example.com/blog/${slug}`,
    })),
  ];
}

Deploying Your Headless Site

  1. Deploy WordPress on any reliable WordPress host. It only needs to serve the admin and API, but it should be secure and backed up.
  2. Deploy Next.js to a platform that supports its caching features, such as Vercel, Netlify, or a Node.js server you manage yourself.
  3. Set environment variables on the hosting platform: WORDPRESS_API_URL and REVALIDATE_SECRET.
  4. Update the WordPress constants so NEXTJS_REVALIDATE_URL points to the production domain.
  5. Test the full flow: Publish a post, confirm it appears, update it, and confirm the change shows up.

Common Problems and Fixes

"Hostname is not configured" for images: Add the WordPress domain to images.remotePatterns in next.config.ts and restart.

Content does not update: Check that the revalidation webhook is being called (your hosting logs will show requests to /api/revalidate) and that the secret matches on both sides.

Weird characters in titles: Titles contain HTML entities. Render them as HTML or decode them with a small utility.

Only 10 posts appear: The REST API returns 10 items by default. Use per_page (up to 100) and pagination for more.

Build is slow with many posts: Pre-render only recent or popular posts in generateStaticParams() and let the rest render on demand.


FAQ: WordPress with Next.js

Both work well. The REST API needs no plugin and is great for simple sites. WPGraphQL lets you request exactly the fields you need, including related data, in a single query, which many teams prefer for larger projects.

No. With cache tags and on-demand revalidation, only the affected data is refreshed. New posts render on first request and are then cached.

Yes, as a source of SEO data. Both plugins can expose metadata through the API, which you then output in Next.js with generateMetadata.

Plugins that only output front-end HTML will not appear automatically. You need to build the form in Next.js and submit it to an API, for example a form plugin's REST endpoint or a separate service.

Any reputable WordPress host works. Since the front end handles most traffic, the back end mainly needs to be secure, reliable, and fast enough for API requests.

It is reasonable when the HTML comes from your own CMS and editors are trusted. If untrusted users can submit content, sanitize the HTML on the server first with a library like sanitize-html.

Yes, but the App Router is the recommended approach for new projects. It supports Server Components, streaming, and tag-based caching that fit headless WordPress very well.


Conclusion

Using WordPress with Next.js gives you the best of both worlds: a mature, editor-friendly CMS and a fast, flexible React front end. With a small typed API client, Server Components for data fetching, dynamic routes for posts, and next/image for media, you can build a complete headless blog in an afternoon.

The real power comes from caching and on-demand revalidation. By tagging your fetches and letting WordPress call a Route Handler when content changes, your site stays both fast and up to date. From here, you can add previews, pagination, categories, and custom post types, growing your headless setup one piece at a time.

Tags :
Share :

Related Posts

What are the best WordPress security plugins?

What are the best WordPress security plugins?

The best WordPress security plugins for most sites are Wordfence, Sucuri Security, Solid Security, MalCare, All-In-One Security (AIOS), Patchstack, a

Dive Deeper
What is the difference between posts and pages in WordPress?

What is the difference between posts and pages in WordPress?

The main difference between posts and pages in WordPress is that posts are timely, dated entries that appear in your blog feed, archives, and RSS fee

Dive Deeper
How much does it cost to build a WordPress website?

How much does it cost to build a WordPress website?

Building a WordPress website can cost anywhere from roughly the price of a domain and a year of budget hosting, if you do it yourself with free tools

Dive Deeper