Headless WordPress with Next.js means WordPress only stores and edits your content, while a Next.js app fetches that content through the WordPress REST API or WPGraphQL and renders the website. Editors keep the WordPress dashboard they know, and visitors get a fast React front end that you control completely.
This guide walks through the whole setup for Next.js 16: choosing between the REST API and WPGraphQL, fetching and caching posts, updating pages the moment an editor clicks Publish, handling contact forms (the part most tutorials skip), SEO, and hosting. I build both WordPress sites and Next.js apps, so I'll also tell you when headless isn't worth it.
Short answer
- WordPress stays the CMS: posts, pages, media, users and plugins. Its own theme is no longer shown to visitors.
- Next.js is the website. It reads content from
https://your-cms.com/wp-json/wp/v2/...(REST API) or from a GraphQL endpoint added by the WPGraphQL plugin. - Caching keeps it fast: pages are prerendered, and a small webhook tells Next.js to refresh them when content changes.
- Forms, previews, SEO tags and redirects no longer come for free from the theme. You rebuild each of them on purpose.
What is headless WordPress?
A normal WordPress site does two jobs in one place. It stores content in the database, and its theme turns that content into HTML pages for every visitor. "Headless" removes the second job. WordPress keeps the database and the dashboard (the body), but the theme (the head) is replaced by a separate front end that asks WordPress for content over an API.
That front end can be anything that speaks HTTP: Next.js, Astro, Nuxt, a mobile app, or all of them at once. Next.js is the most common choice because it can prerender pages like a static site, render on the server when it must, and still behave like a React app in the browser.
WordPress has shipped a built-in REST API since version 4.7, so every WordPress site is already a headless CMS whether it's used that way or not. Visit /wp-json/wp/v2/posts on any WordPress site and you'll see its posts as JSON.
When headless WordPress with Next.js is worth it
Headless is a good fit when:
- The front end is really an app. User accounts, dashboards, search-heavy catalogs, or interactive tools that a theme makes awkward.
- One CMS feeds several places. The same content goes to a website, a mobile app and maybe a second brand site.
- Performance and design control matter more than plugins. You want every component and every kilobyte to be your choice.
- Your team writes React. The people maintaining the site are front-end developers, not theme developers.
It's the wrong choice when:
- Editors rely on a page builder. Elementor, Divi and most block layouts are built for the WordPress theme. Their output doesn't carry over cleanly to a React front end.
- The site leans on front-end plugins. Sliders, popups, booking widgets, membership content and most form plugins render through the theme. Each one becomes custom work.
- Budget covers one system, not two. You'll host, update and secure WordPress and a Next.js app.
For a fuller comparison of the two platforms, see Next.js vs WordPress.
WordPress as a headless CMS: REST API or WPGraphQL
You have two ways to get content out of WordPress.
| REST API | WPGraphQL | |
|---|---|---|
| Setup | Built into WordPress core | Free plugin (version 2.26.0, about 30,000 active installs at the time of writing) |
| Request shape | One URL per resource, for example /wp/v2/posts?slug=hello |
One endpoint; you ask for exactly the fields you need |
| Over-fetching | Trim it with the _fields parameter |
None; queries are precise |
| Custom fields | Register them for the REST API | Register them in the GraphQL schema |
| Plugin support | Most plugins add REST routes | Many popular plugins have WPGraphQL extensions |
| Best for | Simple blogs and sites, fewer moving parts | Complex content models and nested data |
My default is the REST API for blogs and marketing sites, because there's nothing extra to install or keep updated. I reach for WPGraphQL when a page needs data from several post types and taxonomies in one request. The examples below use REST.
Step 1: Prepare WordPress
- Put WordPress on its own domain. A subdomain such as
cms.example.comis common. Your Next.js site takes the main domain. - Set pretty permalinks. Under Settings → Permalinks, choose anything except "Plain". The
/wp-json/routes depend on it. - Check the API. Open
https://cms.example.com/wp-json/wp/v2/posts. You should see JSON. - Keep the CMS out of Google. Visitors should only see the Next.js site. Block the CMS domain from search with a
noindexheader or a robots rule, and keep a minimal theme active for the dashboard's previews. - Plan authentication only where you need it. Published posts and pages are public through the API. Drafts, private content and writes need authentication, usually an Application Password (built in since WordPress 5.6) used only on the server.
Step 2: Fetch posts in Next.js 16
Create an app with npx create-next-app@latest. New App Router apps already turn on Cache Components (cacheComponents: true in next.config.ts), which gives you the 'use cache' directive used below. Add your CMS address to .env.local:
WORDPRESS_URL=https://cms.example.com
Keep all WordPress requests in one file, so caching and error handling live in a single place:
// lib/wordpress.ts
import { cacheLife, cacheTag } from 'next/cache'
const WP = process.env.WORDPRESS_URL
export type Post = {
id: number
slug: string
date: string
title: { rendered: string }
excerpt: { rendered: string }
content: { rendered: string }
}
async function wp<T>(path: string): Promise<T> {
const res = await fetch(`${WP}/wp-json/wp/v2/${path}`)
if (!res.ok) throw new Error(`WordPress returned ${res.status} for ${path}`)
return res.json()
}
export async function getPosts() {
'use cache'
cacheLife('hours')
cacheTag('posts')
return wp<Post[]>('posts?per_page=20&_fields=id,slug,date,title,excerpt')
}
export async function getPost(slug: string) {
'use cache'
cacheLife('hours')
cacheTag('posts')
const posts = await wp<Post[]>(`posts?slug=${encodeURIComponent(slug)}&_fields=id,slug,date,title,excerpt,content`)
return posts[0]
}
'use cache' stores each result, cacheLife('hours') gives it a lifetime, and cacheTag('posts') labels it so you can refresh every post at once later. The REST API returns 10 posts by default; per_page raises that, up to 100 per request.
Now the post page. In Next.js 16, params is a Promise, so it's awaited:
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getPost, getPosts } from '@/lib/wordpress'
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((post) => ({ slug: post.slug }))
}
export default async function Page({ params }: PageProps<'/blog/[slug]'>) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound()
return (
<article>
<h1 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
<div dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
</article>
)
}
generateStaticParams prerenders the posts it returns at build time. Posts published later are rendered on their first visit and then cached. content.rendered is HTML produced by WordPress, so only render it from a CMS whose editors you trust, or sanitize it first.
Step 3: Update pages when an editor publishes
Cached pages are fast, but editors expect a published change to appear right away. The fix is a small webhook: WordPress calls a Next.js route, and that route refreshes everything tagged posts.
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
export async function POST(request: Request) {
if (request.headers.get('x-revalidate-secret') !== process.env.REVALIDATE_SECRET) {
return Response.json({ revalidated: false }, { status: 401 })
}
revalidateTag('posts', 'max')
return Response.json({ revalidated: true })
}
In Next.js 16, revalidateTag takes a second argument. 'max' marks the data stale and refreshes it in the background on the next visit. If the old version must never be shown again, pass { expire: 0 } instead.
On the WordPress side, a must-use plugin (a PHP file in wp-content/mu-plugins/) calls that route whenever a post is published, updated or unpublished. Define NEXT_REVALIDATE_SECRET in wp-config.php with the same value as REVALIDATE_SECRET:
<?php
add_action( 'transition_post_status', function ( $new_status, $old_status, $post ) {
if ( 'post' !== $post->post_type || ( 'publish' !== $new_status && 'publish' !== $old_status ) ) {
return;
}
wp_remote_post( 'https://www.example.com/api/revalidate', array(
'headers' => array( 'x-revalidate-secret' => NEXT_REVALIDATE_SECRET ),
'timeout' => 5,
'blocking' => false,
) );
}, 10, 3 );
Contact forms in headless WordPress
This is where most headless builds get stuck. Form plugins render their forms through the WordPress theme, and the theme is gone. You have two practical options.
Option 1: Contact Form 7's REST API
Contact Form 7 already has a public endpoint: POST /wp-json/contact-form-7/v1/contact-forms/{id}/feedback. I read its source code (version 6.2.4.1) before writing this, and three details trip people up:
- It only accepts
multipart/form-data. Sending JSON returns a 415 "payload format is not supported" error. - It requires a
_wpcf7_unit_tagfield. Without it you get a 400 "no valid unit tag" error. Any value made of letters, numbers,_and-passes. - The URL needs the numeric form ID, the one in the form's edit URL in the dashboard, not the hashed ID shown in newer shortcodes.
A Next.js server action handles it neatly, because the request goes from your server to WordPress:
// app/contact/actions.ts
'use server'
const WP = process.env.WORDPRESS_URL
const FORM_ID = process.env.CF7_FORM_ID
export async function sendContact(formData: FormData) {
const body = new FormData()
body.append('_wpcf7_unit_tag', `wpcf7-f${FORM_ID}-o1`)
body.append('your-name', String(formData.get('name') ?? ''))
body.append('your-email', String(formData.get('email') ?? ''))
body.append('your-subject', String(formData.get('subject') ?? ''))
body.append('your-message', String(formData.get('message') ?? ''))
const res = await fetch(`${WP}/wp-json/contact-form-7/v1/contact-forms/${FORM_ID}/feedback`, { method: 'POST', body })
const data = await res.json()
return { sent: data.status === 'mail_sent', message: data.message, errors: data.invalid_fields }
}
The field names must match the form's tags (your-name, your-email and so on in the default form). A successful send returns status: "mail_sent"; a failed validation returns validation_failed with an invalid_fields list. One limit to know: if the form uses Contact Form 7's reCAPTCHA integration, a submission without a reCAPTCHA token is marked as spam, so a headless form needs its own token handling or a different spam check.
Option 2: A headless form backend plugin
If you'd rather not build a form in the WordPress dashboard just to mirror it in React, a form backend accepts whatever fields your form sends. I wrote and maintain SubmitDock, a free plugin on WordPress.org that does exactly this: it adds POST /wp-json/submitdock/v1/submit, saves every submission in the WordPress dashboard, emails it to you, and accepts JSON or a plain HTML form post.
Call it from the browser rather than from a server action. SubmitDock's spam protection counts submissions per visitor IP address (5 per 15 minutes by default) and its "allowed websites" setting checks the browser's Origin header, and both only work when the request comes from the visitor's browser:
// app/contact/contact-form.tsx
'use client'
import { useState } from 'react'
const ENDPOINT = `${process.env.NEXT_PUBLIC_WORDPRESS_URL}/wp-json/submitdock/v1/submit/contact`
export default function ContactForm() {
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setStatus('sending')
const form = event.currentTarget
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
})
setStatus(res.ok ? 'sent' : 'error')
if (res.ok) form.reset()
}
return (
<form onSubmit={onSubmit}>
<label>Name <input name="name" required /></label>
<label>Email <input name="email" type="email" required /></label>
<label>Message <textarea name="message" required /></label>
<input name="_gotcha" tabIndex={-1} autoComplete="off" aria-hidden="true" style={{ position: 'absolute', left: '-9999px' }} />
<button disabled={status === 'sending'}>Send</button>
<p role="status">{status === 'sent' ? 'Thanks, your message was sent.' : status === 'error' ? 'Something went wrong. Please try again.' : ''}</p>
</form>
)
}
The /contact at the end of the URL names the form, so you can filter submissions by form in the dashboard. _gotcha is the default spam trap field: real visitors never fill it in. Add your Next.js domain under the plugin's allowed websites, and replies to the notification email go straight to the address in the email field.
SEO for headless WordPress
Headless sites can rank as well as any other site, but the theme used to do several SEO jobs for you, and now you do them in Next.js:
- Meta tags and schema. If you use Yoast SEO, its REST fields
yoast_headandyoast_head_jsoncome back with each post, and/wp-json/yoast/v1/get_head?url=returns them for any URL. Map them intogenerateMetadataso editors keep control of titles and descriptions. - Canonical URLs. Point them at the Next.js domain, never the CMS domain.
- Sitemap and robots. Generate them in Next.js (
app/sitemap.ts,app/robots.ts) from the same API calls. WordPress's own sitemap lists CMS URLs. - Redirects. Old URLs from a previous theme need 301 redirects in
next.config.tsor at your host. - Previews. WordPress's Preview button opens the CMS theme by default. Wire it to Next.js Draft Mode with an authenticated request so editors see the real page.
- Images. Use
next/imagewith the CMS domain allowed inimages.remotePatterns, so WordPress uploads get resized and lazy-loaded.
Headless WordPress hosting
You host two things:
- WordPress on any PHP host. It only serves the dashboard and the API, so it needs less power than a normal WordPress site, but it must be fast and reliable enough to answer the build and revalidation requests.
- Next.js on Vercel, Netlify, or your own Node.js server. Cache Components and on-demand revalidation work on all of them, but a self-hosted Node server needs its cache configured, while a platform handles it for you.
Some managed WordPress hosts sell combined headless plans that host both sides together. Whatever you choose, put the CMS behind HTTPS, keep WordPress and its plugins updated (a headless CMS still runs PHP), and never expose the Application Password or revalidation secret in browser code.
Common mistakes
- Fetching from the CMS on every request. Without
'use cache'or tags, every visit hits WordPress. Cache it and revalidate on publish. - Sending JSON to Contact Form 7. Its feedback endpoint only takes
multipart/form-dataand a_wpcf7_unit_tag. - Letting Google index the CMS domain. You get two copies of every page. Noindex the CMS and set canonicals to the front end.
- Choosing headless for a page-builder site. Elementor layouts don't move to React. Either keep the theme or plan a redesign.
- Forgetting previews and redirects. Editors and old URLs both break quietly at launch.
Frequently asked questions
Is WordPress a headless CMS?
Yes, if you use it that way. Every WordPress site since version 4.7 has a built-in REST API, so it can serve content to any front end. It becomes headless when visitors see that front end instead of the WordPress theme.
How do I make WordPress headless?
Keep WordPress for editing, build a front end such as Next.js that reads /wp-json/wp/v2/ (or WPGraphQL), host that front end on your main domain, and keep the WordPress domain out of search results.
Is headless WordPress worth it?
It's worth it when the front end is an app, when one CMS feeds several channels, or when your team works in React. For a brochure site or blog built with a page builder, a well-built WordPress theme is cheaper and easier to run.
Is headless WordPress bad for SEO?
No. Next.js prerenders full HTML, which search engines read easily. The risk is forgetting what the theme used to handle: meta tags, canonicals, sitemaps, redirects and structured data.
Can WooCommerce run headless?
Yes. WooCommerce's Store API powers its block cart and checkout and can serve a custom front end, and plugins such as CoCart add more REST routes. Payments and checkout need the most care, so many stores keep checkout on WordPress.
Can I use Contact Form 7 with headless WordPress?
Yes, through its REST endpoint /wp-json/contact-form-7/v1/contact-forms/{id}/feedback, as shown above. Send multipart/form-data, include _wpcf7_unit_tag, and use the numeric form ID.
When to get help
A headless blog with the steps above takes a few days. It gets harder with custom post types and fields, WooCommerce, memberships, multilingual content, previews for editors, or a migration from an existing theme with years of URLs to keep. That's the work I do as part of my headless WordPress development services, from the WordPress API side to the Next.js front end, and through custom REST API development when WordPress needs new endpoints.
