Editors like WordPress. Visitors like fast pages. A headless setup gives both: editors keep the WordPress dashboard, and a Next.js frontend renders the public site from the WordPress REST API. This site works this way, and so does ToolyConvert.
Headless is not automatically better, though. This guide covers when it makes sense, then the parts that decide whether it works in production: fetching, caching, revalidation, content safety, images and SEO.
When headless WordPress is worth itLink to this section
Headless adds a second application to build, host and monitor. It pays off when at least one of these is true:
- Performance matters commercially. A server-rendered Next.js page with cached data and optimized images is easier to keep fast than a theme with a dozen plugins adding scripts.
- The frontend does more than show posts. Interactive tools, dashboards or app-like flows are easier in React than in a PHP theme.
- Content feeds more than one channel. The same REST API can serve the website, a mobile app and an internal tool.
It is usually not worth it for a brochure site whose owner relies on page builders, plugin shortcodes or form plugins that render on the frontend. Those features stop working once WordPress no longer renders the page.
The architecture in one pictureLink to this section
Editor ──► WordPress (api.example.com)
│ REST API: /wp-json/wp/v2/posts, /categories, custom endpoints
│ on publish: POST /api/revalidate (secret header)
▼
Next.js (www.example.com)
│ Server Components fetch + cache data
│ ISR: cached pages refreshed on a timer or on demand
▼
VisitorKeep WordPress on its own subdomain and keep that host out of search results. More on that in the SEO section.
Fetching posts with a typed clientLink to this section
The REST API is enough for most sites. WPGraphQL is a good alternative when you need deeply nested data in one request, but it adds a plugin and a schema to maintain.
Two query parameters do most of the work:
_embedincludes the author, featured image and terms in the same response, which saves round-trips._fieldstrims the payload to what you use. When combined with_embed, include_linksand_embeddedin the list.
// lib/wordpress.ts (server-only)
const WP = process.env.WORDPRESS_API_URL; // e.g. https://api.example.com
export async function getPosts(page = 1) {
const url = new URL(`${WP}/wp-json/wp/v2/posts`);
url.searchParams.set("per_page", "9");
url.searchParams.set("page", String(page));
url.searchParams.set("_embed", "author,wp:featuredmedia,wp:term");
const res = await fetch(url, {
next: { revalidate: 600, tags: ["wp", "wp:posts"] },
signal: AbortSignal.timeout(10_000),
});
// WordPress answers 400 for a page number past the end.
if (res.status === 400) return { posts: [], totalPages: 0 };
if (!res.ok) throw new Error(`WordPress returned ${res.status}`);
return {
posts: await res.json(),
totalPages: Number(res.headers.get("x-wp-totalpages") ?? 0),
};
}Pagination totals arrive in the X-WP-Total and X-WP-TotalPages headers, not in the body. Map the raw response to your own Post type in one place so that components never touch title.rendered or _embedded["wp:featuredmedia"][0] directly. When a response looks wrong, paste it into the JSON formatter to see the exact shape you're getting.
Caching and revalidationLink to this section
In the App Router, fetch results are cached when you give them a revalidate time. A page built from those requests becomes an ISR page: it is served from cache and refreshed in the background once the time passes.
A timer alone means a new post can take up to ten minutes to appear. On-demand revalidation fixes that. WordPress calls a secret-protected route in Next.js whenever content changes:
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from "next/cache";
import { timingSafeEqual } from "node:crypto";
export async function POST(req: Request) {
const given = Buffer.from(req.headers.get("x-revalidate-secret") ?? "");
const secret = Buffer.from(process.env.REVALIDATE_SECRET ?? "");
if (secret.length < 16 || given.length !== secret.length || !timingSafeEqual(given, secret)) {
return Response.json({ ok: false }, { status: 401 });
}
const { slug } = await req.json().catch(() => ({}));
revalidateTag("wp");
if (slug) revalidatePath(`/blog/${slug}`);
return Response.json({ ok: true });
}On the WordPress side, send the request when a post enters or leaves the published state. Make it non-blocking so editors never wait for the frontend:
add_action( 'transition_post_status', function ( $new, $old, $post ) {
if ( 'post' !== $post->post_type || ( 'publish' !== $new && 'publish' !== $old ) ) {
return;
}
wp_remote_post( HI_FRONTEND_URL . '/api/revalidate', array(
'blocking' => false,
'timeout' => 3,
'headers' => array(
'Content-Type' => 'application/json',
'x-revalidate-secret' => HI_REVALIDATE_SECRET,
),
'body' => wp_json_encode( array( 'slug' => $post->post_name ) ),
) );
}, 10, 3 );Define HI_FRONTEND_URL and HI_REVALIDATE_SECRET in wp-config.php so the secret never sits in the database or in version control.
Keep the timer as a safety net even with webhooks. If a webhook fails because the frontend was redeploying, the page still refreshes on its next interval.
Static params for article pagesLink to this section
Pre-render your most recent articles at build time and let older ones render on first request:
// app/blog/[slug]/page.tsx
export const revalidate = 600;
export const dynamicParams = true;
export async function generateStaticParams() {
const { posts } = await getPosts(1);
return posts.map((p: { slug: string }) => ({ slug: p.slug }));
}Wrap these calls in try/catch and return an empty array on failure. Otherwise a short WordPress outage during a deploy fails the whole build.
Treat WordPress HTML as untrustedLink to this section
content.rendered is HTML written by people and plugins. Before it goes into dangerouslySetInnerHTML, sanitize it on the server with an allowlist library such as sanitize-html:
- Allow normal content tags, images, figures, tables and code.
- Allow iframes only from hosts you trust, such as YouTube and Vimeo.
- Drop inline
styleattributes and everyon*event handler. - Add
rel="noopener"to external links.
Sanitizing is also the right moment to add heading anchors for a table of contents and syntax highlighting for code blocks, so the browser receives finished HTML and almost no JavaScript.
ImagesLink to this section
Featured images come from _embedded["wp:featuredmedia"][0]. Prefer a generated size such as large over the full original, and pass the width and height to next/image so the browser reserves space and avoids layout shift. Allow the WordPress host in next.config.ts:
images: {
remotePatterns: [{ protocol: "https", hostname: "api.example.com" }],
},Images inside post content are plain <img> tags. Add loading="lazy" and decoding="async" to them during sanitizing. WordPress already outputs srcset, so they stay responsive.
Previews and authenticationLink to this section
Draft previews need authenticated requests. The simplest secure option is a WordPress Application Password stored only in server environment variables, combined with Next.js Draft Mode. If you use a JWT authentication plugin instead, never put tokens in NEXT_PUBLIC_ variables. The JWT decoder helps you check what a token contains and when it expires.
SEO for a headless setupLink to this section
Most headless SEO problems come from the same content being reachable on two hosts.
- Keep the API host out of the index. Send
X-Robots-Tag: noindexon every non-REST response from WordPress, disable its core XML sitemaps and redirect theme URLs to the frontend. - Set canonicals in Next.js, built from your public origin, never from WordPress's
linkfield. - Generate the sitemap in Next.js with
app/sitemap.ts, from the same data the pages use. - Add structured data (
BlogPosting,BreadcrumbList,PersonorOrganization) as JSON-LD. The schema generator gives you a valid starting block. - Redirect old URLs in one hop. If posts used to live at
/post-name/, add a catch-all route that looks up the slug and issues a single permanent redirect to/blog/post-name.
The technical SEO checklist for Next.js and WordPress goes through each of these in detail.
Common problems and fixesLink to this section
| Problem | Usual cause | Fix |
|---|---|---|
| CORS errors in the browser console | Data is fetched in client components | Fetch in Server Components or route handlers; server-to-server requests don't need CORS |
| A published post doesn't appear | The webhook failed or used a different tag or path | Check the route's response in the WordPress side (a 401 means the secret differs) and log the revalidated tags |
next/image returns a 400 error |
The WordPress host isn't in remotePatterns |
Add the exact hostname, including any CDN host for uploads |
| Post content looks unstyled | Block markup arrives without WordPress's block CSS | Port the styles you use (buttons, columns, galleries) into the frontend's stylesheet |
| Builds fail when WordPress is slow | Build-time fetches without a timeout or fallback | Add AbortSignal.timeout() and try/catch around generateStaticParams |
| Previews show the published version | Draft Mode not enabled, or the request is cached | Enable Draft Mode in the preview route and fetch drafts with cache: "no-store" |
Production checklistLink to this section
- ☐ WordPress on its own subdomain,
noindexon non-REST responses. - ☐ Typed mapping layer between raw REST responses and components.
- ☐
revalidatetimer and webhook revalidation with a secret. - ☐ Builds survive a WordPress outage (
try/catcharound build-time fetches). - ☐ Content HTML sanitized on the server.
- ☐
next/imagewith real dimensions; remote host allowlisted. - ☐ Canonicals, sitemap and JSON-LD generated by Next.js.
- ☐ Legacy URLs redirected in one hop.
- ☐ Secrets only in server environment variables.
Frequently asked questionsLink to this section
Do WordPress plugins still work in a headless setup?Link to this section
Plugins that work in the admin or the database keep working: SEO fields, custom fields, editorial workflow, user roles. Plugins that output HTML on the public site, such as form builders, sliders and page builders, don't, because WordPress no longer renders those pages. Plan a replacement for each one before you commit.
REST API or WPGraphQL?Link to this section
Start with the REST API: it's built in and enough for most sites. Move to WPGraphQL when pages need deeply nested data, such as a post with its author, related posts and custom fields, and the number of REST requests becomes a problem.
Where should each part be hosted?Link to this section
WordPress can stay on ordinary PHP hosting, ideally on its own subdomain. Next.js needs a Node.js server or a platform such as Vercel. Keep them independent, so a WordPress outage only delays content updates and doesn't take the public site down.
Headless WordPress works best when the boundary is clear: WordPress owns content, Next.js owns presentation, caching and SEO. If you're weighing a move to headless, or your current headless build is slow or hard to update, get in touch.
Test yourself
3 quick questions on this lesson.
Finished this lesson?
Mark it complete to track your progress through the learning paths.