Type something to search...
How to Connect WordPress to Next.js as a Headless CMS?

How to Connect WordPress to Next.js as a Headless CMS?

Connecting WordPress to Next.js means fetching content from WordPress's API and rendering it with React components instead of PHP templates, turning WordPress into a pure content backend while Next.js owns everything a visitor actually sees. If you've read what headless WordPress is and how it works, this is the practical follow-up: an actual, working integration between the two.

This guide walks through setting up WordPress to serve content over its REST API, fetching that content from a Next.js App Router project, rendering posts as static pages, and handling the pieces (previews, revalidation, images) that trip people up the first time they try this.

What You Need Before Starting

  • A WordPress installation reachable over HTTPS, either self-hosted or managed, with at least one published post.
  • A Next.js project using the App Router (Next.js 13+).
  • No required plugins for basic REST API access, it's built into WordPress core, though you'll likely want Advanced Custom Fields with its REST API add-on if you need custom fields exposed in API responses.

Step 1: Confirm the REST API Is Reachable

Every WordPress site exposes its REST API by default. Confirm yours is live by visiting:

https://your-wordpress-site.com/wp-json/wp/v2/posts

You should get back a JSON array of your published posts. If you get a 404, check that pretty permalinks are enabled under Settings → Permalinks (the REST API relies on rewrite rules that plain "default" permalinks don't set up), and check that no security plugin is blocking /wp-json/ routes. For a full breakdown of what this API returns and how to query it, see how to use the WordPress REST API.

Step 2: Set Up Environment Variables in Next.js

Keep your WordPress URL in an environment variable rather than hardcoding it, so staging and production can point at different WordPress instances without a code change:

# .env.local
WORDPRESS_API_URL=https://your-wordpress-site.com/wp-json/wp/v2

Step 3: Write a Small API Client

Rather than calling fetch() directly all over your components, centralize the WordPress requests in one file:

// lib/wordpress.ts
const API_URL = process.env.WORDPRESS_API_URL;

export async function getAllPosts() {
  const res = await fetch(`${API_URL}/posts?_embed&per_page=100`, {
    next: { revalidate: 3600 },
  });

  if (!res.ok) {
    throw new Error(`Failed to fetch posts: ${res.status}`);
  }

  return res.json();
}

export async function getPostBySlug(slug: string) {
  const res = await fetch(`${API_URL}/posts?slug=${slug}&_embed`, {
    next: { revalidate: 3600 },
  });

  if (!res.ok) {
    throw new Error(`Failed to fetch post: ${res.status}`);
  }

  const posts = await res.json();
  return posts[0] ?? null;
}

_embed matters here: without it, a post's featured image and author come back only as numeric IDs, and you'd need a second request per post to resolve them. With _embed, WordPress includes that related data inline under an _embedded key, one request instead of several.

Step 4: Build a Static Blog Listing Page

// app/blog/page.tsx
import Link from "next/link";
import { getAllPosts } from "@/lib/wordpress";

export default async function BlogIndex() {
  const posts = await getAllPosts();

  return (
    <div>
      <h1>Blog</h1>
      <ul>
        {posts.map((post: any) => (
          <li key={post.id}>
            <Link href={`/blog/${post.slug}`}>
              <span dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

Step 5: Build the Single Post Page with Static Generation

To pre-render every post at build time rather than fetching on every request, export generateStaticParams alongside the page component:

// app/blog/[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 default async function PostPage({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getPostBySlug(params.slug);

  if (!post) {
    notFound();
  }

  const featuredImage =
    post._embedded?.["wp:featuredmedia"]?.[0]?.source_url ?? null;

  return (
    <article>
      <h1 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
      {featuredImage && (
        // eslint-disable-next-line @next/next/no-img-element
        <img src={featuredImage} alt="" />
      )}
      <div dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
    </article>
  );
}

generateStaticParams tells Next.js every slug that exists at build time, so each post gets pre-rendered to static HTML instead of hitting WordPress on every visitor's request. The revalidate: 3600 option set earlier means Next.js will re-fetch and regenerate a page in the background at most once an hour after it's been requested, using Incremental Static Regeneration, so new WordPress content shows up without a full redeploy.

Step 6: Rebuild Automatically When Content Changes

Static generation is fast, but it means new posts don't appear until the next build (or the next revalidation window). Two common ways to close that gap:

  • A WordPress webhook plugin (or a small custom action hooked to publish_post) that pings a Next.js on-demand revalidation endpoint or triggers a new deployment whenever content is published or updated.
  • On-demand revalidation, using Next.js's revalidatePath() inside an API route that WordPress calls via a webhook:
// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const secret = req.nextUrl.searchParams.get("secret");

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

  const { slug } = await req.json();
  revalidatePath(`/blog/${slug}`);

  return NextResponse.json({ revalidated: true });
}

A WordPress-side hook (added to a must-use plugin) calling this endpoint on save_post closes the loop, so publishing in WordPress triggers a near-instant update on the Next.js front end without waiting for the revalidation window or a full rebuild.

Step 7: Handle Previews for Draft Content

WordPress's default "Preview" button expects a PHP theme to render the draft, which doesn't exist in a headless setup. Building a preview mode means authenticating the request (so only editors can view unpublished content) and fetching the draft via the REST API using an authenticated request, since draft posts aren't returned to unauthenticated requests by default. Next.js's Draft Mode API pairs well with this: a preview link from WordPress hits a route that enables draft mode and redirects to the post, and your data-fetching functions check that mode to decide whether to request draft or published content.

Step 8: Generate SEO Metadata from WordPress Data

Since there's no PHP theme rendering <title> and meta tags, Next.js's Metadata API takes over that job, populated from whatever fields WordPress exposes. Export a generateMetadata function alongside the page component:

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPostBySlug } from "@/lib/wordpress";

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

  if (!post) {
    return {};
  }

  const featuredImage = post._embedded?.["wp:featuredmedia"]?.[0]?.source_url;

  return {
    title: post.title.rendered,
    description: post.excerpt.rendered.replace(/<[^>]+>/g, "").trim(),
    openGraph: {
      title: post.title.rendered,
      images: featuredImage ? [featuredImage] : [],
    },
  };
}

If you're running an SEO plugin like Yoast or Rank Math, both expose their own computed meta title and description fields through the REST API once you enable that in the plugin's settings, which is generally more reliable to use here than deriving a description from the raw excerpt, since it respects whatever an editor customized in the plugin's metabox.

Step 9: Rendering Category and Taxonomy Archives

A category archive follows the same fetch-and-render pattern as a single post, just querying by taxonomy term instead of by slug. First, resolve the category's ID (WordPress's REST API filters posts by term ID, not by term slug, for the built-in categories parameter):

// lib/wordpress.ts
export async function getCategoryBySlug(slug: string) {
  const res = await fetch(`${API_URL}/categories?slug=${slug}`, {
    next: { revalidate: 3600 },
  });
  const categories = await res.json();
  return categories[0] ?? null;
}

export async function getPostsByCategoryId(categoryId: number) {
  const res = await fetch(
    `${API_URL}/posts?categories=${categoryId}&_embed&per_page=20`,
    { next: { revalidate: 3600 } },
  );
  return res.json();
}

Then combine both calls in a category archive page, resolving the slug to an ID first, and generating a static path per category the same way generateStaticParams did for individual posts earlier.

Common Pitfalls

  • CORS errors when fetching client-side. If you fetch WordPress data directly from the browser rather than in a server component, WordPress needs to send Access-Control-Allow-Origin headers for your Next.js domain. Fetching server-side (as shown above) avoids this entirely, since the request never touches the browser.
  • dangerouslySetInnerHTML everywhere. WordPress's content.rendered and title.rendered fields are pre-rendered HTML strings, not plain text or React-safe markup, so rendering them requires dangerouslySetInnerHTML. This is standard practice for headless WordPress, but make sure you trust the source (your own WordPress install with trusted authors) since it bypasses React's automatic escaping.
  • Forgetting dynamicParams = false equivalents. If your project needs every possible slug pre-rendered and nothing else, make sure generateStaticParams genuinely returns every slug; an incomplete list combined with strict static export settings will 404 on real content.
  • Not caching WPGraphQL or REST responses at all. Hitting the WordPress API on every single request, with no revalidate or caching layer, largely erases the performance benefit of going headless in the first place.

Frequently Asked Questions (FAQ) About Connecting WordPress to Next.js

The built-in REST API is enough for most sites and requires no plugin installation. WPGraphQL is worth adding when you want to request exactly the fields you need in a single query, useful for pages that combine data from posts, menus, and custom fields at once, since it avoids the multiple round trips REST sometimes requires.

getStaticProps is part of the Pages Router; the examples above use the App Router's generateStaticParams and async server components instead, which is the current recommended data-fetching pattern in Next.js. The underlying WordPress API calls are identical either way, only the Next.js-side data-fetching API differs.

The core REST API doesn't expose nav menus by default. The common approaches are adding a small custom REST endpoint via register_rest_route() that returns your menu structure, or using WPGraphQL, which exposes registered menus and their items out of the box.

Its admin-side fields (meta title, meta description) are usually still editable and can be exposed via the REST API with some configuration, but its automatic front-end output (meta tags, schema.org markup, XML sitemaps) won't appear anywhere, since there's no WordPress theme rendering the page. You'll need to fetch those fields and render the corresponding tags yourself using Next.js's Metadata API.

Yes. WordPress generates its standard set of image sizes on upload regardless of whether a theme is attached, and the REST API's _embedded media data includes URLs for each available size, so you can still request an appropriately sized image rather than always pulling the full original.

No, the same pattern applies to any public content type, pages, custom post types, WooCommerce products (via WooCommerce's own REST API extension). The examples focus on posts because they're the simplest case, but generateStaticParams and a fetch-based data layer work identically for other content types once their endpoints are enabled.

Conclusion

Connecting WordPress to Next.js comes down to three pieces: confirming WordPress's REST API is reachable, writing a small, centralized data-fetching layer in Next.js, and deciding how new content gets from "published in WordPress" to "visible on the front end," whether that's a revalidation window, an on-demand webhook, or both.

None of this requires abandoning what makes WordPress useful as a CMS. Editors keep the interface they know, and the front end gets full control over rendering, performance, and design, at the cost of the extra moving parts and plugin compatibility tradeoffs covered in what headless WordPress is and how it works. Once the basic fetch-and-render loop above is working, the natural next steps are exploring WPGraphQL for more efficient queries and building out on-demand revalidation so content updates feel instant.

A few additional resources if you want to go deeper:

Tags :
Share :

Related Posts

Effortlessly Crafting Compelling WordPress Pages

Effortlessly Crafting Compelling WordPress Pages

As a website owner or content creator, having the ability to seamlessly add new pages to your WordPress site is crucial. Whether you're introducing a

Continue Reading
High Traffic Tips for WordPress Mastery 🚥

High Traffic Tips for WordPress Mastery 🚥

In our digital age, where online visibility is paramount, ensuring your WordPress site can handle surging traffic is crucial. Just like a finely-tune

Continue Reading
How Do I Change the WordPress Login URL?

How Do I Change the WordPress Login URL?

By default, every WordPress site's login page lives at the same predictable address: yoursite.com/wp-login.php (which also happens to redirect from

Continue Reading