Headless WordPress means WordPress does one job — managing content — while a separate frontend, built here with Next.js, handles everything the visitor sees. The two talk over the WordPress REST API. This tutorial builds the whole thing from an empty folder: a WordPress backend, a Next.js frontend that lists posts, renders single articles, loads images correctly, and refreshes content without a redeploy.

What you are building, and why

In a traditional WordPress site, PHP renders every page (if you prefer monolithic architecture, see our guide on building a custom WordPress theme from scratch). In a headless setup, WordPress becomes a content API and Next.js renders the pages — which means faster page loads (static generation, edge caching), a modern React component workflow, and a frontend that ships exactly the HTML, CSS and JavaScript each page needs. The trade-off is responsibility: routing, SEO tags and image optimisation move from WordPress plugins into your code. The steps below cover all of them.

Close-up of the WordPress admin dashboard open on the Plugins screen

Step 1 — Set up WordPress as the backend

Install WordPress anywhere you like — a local tool such as Local or a Docker container works fine for development. (Before deploying to production, make sure to harden WordPress security without plugins to protect your administrative endpoints). Then make three adjustments:

  • Set permalinks to “Post name” under Settings → Permalinks. The REST API works either way, but pretty permalinks keep your route URLs clean.

  • Add real content. Create four or five posts with featured images, categories and excerpts — empty sites hide layout bugs.

  • Test the API. Visit https://your-site.test/wp-json/wp/v2/posts in a browser. You should see a JSON array of your posts. That endpoint is the entire contract between the two halves of this project.

No headless plugin is required for the REST API approach. (WPGraphQL is a valid alternative if you prefer GraphQL queries; the frontend steps below use REST because it needs zero extra setup.)

Step 2 — Create the Next.js app

npx create-next-app@latest wp-frontend
            cd wp-frontend
            npm run dev

Accept the App Router defaults. Next, store the WordPress URL in an environment variable so no address is hard-coded. Create .env.local:

WP_API_URL=https://your-site.test/wp-json/wp/v2
Close-up of code in an editor — the Next.js frontend files that fetch WordPress content

Step 3 — Fetch posts from the REST API

Create a small data layer at lib/wp.js. Every fetch goes through here, so the API shape lives in exactly one place:

const API = process.env.WP_API_URL;
            
            export async function getPosts() {
              const res = await fetch(
                `${API}/posts?_embed&per_page=20`,
                { next: { revalidate: 60 } }
              );
              if (!res.ok) throw new Error("Failed to fetch posts");
              return res.json();
            }
            
            export async function getPostBySlug(slug) {
              const res = await fetch(
                `${API}/posts?slug=${slug}&_embed`,
                { next: { revalidate: 60 } }
              );
              const posts = await res.json();
              return posts[0] || null;
            }

Two details matter here. _embed tells WordPress to include the featured image and author inside the same response, saving extra requests. And revalidate: 60 turns on Incremental Static Regeneration: pages are static, but Next.js rebuilds them in the background at most once a minute, so new posts appear without a redeploy.

Step 4 — Render the post list

The home page is an async server component that awaits the data directly:

import Link from "next/link";
            import { getPosts } from "@/lib/wp";
            
            export default async function Home() {
              const posts = await getPosts();
              return (
                <main>
                  <h1>Latest posts</h1>
                  {posts.map((post) => (
                    <article key={post.id}>
                      <h2>
                        <Link href={`/posts/${post.slug}`}>
                          {post.title.rendered}
                        </Link>
                      </h2>
                      <div
                        dangerouslySetInnerHTML={{
                          __html: post.excerpt.rendered,
                        }}
                      />
                    </article>
                  ))}
                </main>
              );
            }

Note the shape of the data: WordPress returns title.rendered and content.rendered as HTML strings, so they are injected with dangerouslySetInnerHTML. This is safe as long as the HTML comes from your own trusted editorial backend and contributor accounts are secured — never use this pattern with untrusted third-party input.

Step 5 — Build the single-post route

Create app/posts/[slug]/page.js. Pair it with generateStaticParams so every post is pre-rendered at build time, and generateMetadata so each article carries its own SEO title and description:

import { getPostBySlug, getPosts } from "@/lib/wp";
            import { notFound } from "next/navigation";
            
            export async function generateStaticParams() {
              const posts = await getPosts();
              return posts.map((p) => ({ slug: p.slug }));
            }
            
            export async function generateMetadata({ params }) {
              const { slug } = await params; // Next.js 15+: params is a Promise
              const post = await getPostBySlug(slug);
              if (!post) return {};
              return {
                title: post.title.rendered,
                description: post.excerpt.rendered
                  .replace(/<[^>]*>/g, "")
                  .slice(0, 160),
              };
            }
            
            export default async function PostPage({ params }) {
              const { slug } = await params; // Next.js 15+: params is a Promise
              const post = await getPostBySlug(slug);
              if (!post) notFound();
              return (
                <article>
                  <h1>{post.title.rendered}</h1>
                  <div
                    dangerouslySetInnerHTML={{
                      __html: post.content.rendered,
                    }}
                  />
                </article>
              );
            }

Step 6 — Make images work

Featured images live on the WordPress domain, so Next.js Image optimisation needs that domain allow-listed in next.config.js:

/** @type {import('next').NextConfig} */
            module.exports = {
              images: {
                remotePatterns: [
                  { protocol: "https", hostname: "your-site.test" },
                ],
              },
            };

Then read the image URL from the embedded data — post._embedded["wp:featuredmedia"][0].source_url — and always pass the alt_text field through as the image’s alt attribute. (For optimal performance, make sure your media is encoded properly using our guide on how to convert images to AVIF and WebP). Guard the lookup with optional chaining: posts without a featured image are the number-one crash in headless builds.

Step 7 — Publishing flow and going live

With ISR set to 60 seconds, the workflow is simple: publish in WordPress, and the frontend refreshes itself within a minute. If you need instant updates, add an on-demand revalidation route and call it from a WordPress webhook plugin or a small save_post hook. For deployment, any Node host or Vercel works for the frontend, and WordPress stays on ordinary PHP hosting — the two halves deploy independently, which is the quiet superpower of the whole architecture.

Rule of thumb: if a feature is about content, it belongs in WordPress. If it is about presentation, it belongs in Next.js. Keeping that boundary clean is what makes a headless build maintainable.