Headless WordPress means using WordPress as the CMS (for content editing) while rendering the frontend with a modern JavaScript framework like Next.js, Astro, Nuxt, or SvelteKit. It’s a powerful pattern for sites that need fast, modern frontends but want to keep WordPress’s familiar editor for content teams. The biggest challenge with headless WordPress? Custom fields. This guide covers everything you need to know.
Traditional WordPress: one stack. WordPress PHP renders everything — frontend pages, admin screens, REST API. Your theme handles both content editing (via the admin) and frontend display (via PHP templates).
Headless WordPress: two stacks. WordPress handles content editing and data storage. A separate JavaScript frontend (running on Vercel, Netlify, Cloudflare Pages, etc.) fetches content from WordPress via REST API or GraphQL and renders pages independently.
Traditional: [ WordPress ] → Content → Frontend (PHP templates)
↑ ↑
└──── same server ─────────┘
Headless: [ WordPress ] → Content → [ JavaScript Frontend ]
(CMS) (API) (Next.js / Astro / etc.)
For most WordPress sites, traditional WordPress is still the right choice. Headless is the right choice when you need its specific advantages and accept the complexity.
Custom fields are one of the biggest friction points in headless WordPress. Here’s why.
WordPress core has standard fields — title, content, excerpt, featured image, author, date. These are well-defined, consistent across all WordPress sites, and fully supported by headless frontend SDKs (wp-rest-api client libraries, WPGraphQL, etc.).
Custom fields are plugin-defined. ACF adds its own fields, stored in its own way, exposed via its own API. Meta Box does the same. Pods, Toolset, CMB2, Carbon Fields — each has its own approach.
Frontend SDKs don’t know about custom fields because they can’t — the definitions live in plugin code, not core WordPress.
TypeScript can’t infer custom field types from a WordPress API response. A fetch('/wp-json/wp/v2/posts/123') call returns a generic Post object plus an “ACF” property with whatever fields the plugin added. TypeScript sees this as any or Record<string, unknown>.
The result: data.custom_fields.hero_title compiles fine even if the field is named hero_headline. Typos become runtime errors.
A developer adds a new field group on their local WordPress. They push code to staging. The staging WordPress doesn’t have that field group yet. The frontend code that expects the new field breaks.
Without schema synchronization, headless WordPress becomes a debugging nightmare.
WordPress’s REST API exposes custom fields inconsistently:
meta (default WP core behavior)acf (ACF’s integration)For a Next.js component to render a Hero block, it might need 3 separate API calls: one for the post, one for the featured image, one for the related post referenced in a relationship field. This is slow and complex.
GraphQL (via WPGraphQL) is a better fit for headless because it lets you query deeply nested data in a single request. But WPGraphQL doesn’t know about custom fields — you need a separate “WPGraphQL for ACF” plugin to bridge the gap, and it’s maintained independently, so it lags behind ACF updates.
Field Forge was designed with headless WordPress in mind. It addresses every pain point listed above.
Instead of wp_postmeta, Field Forge stores field values in a dedicated indexed table. API responses that pull custom fields are dramatically faster — 3–10x faster on large sites. For headless workflows where the frontend fetches content from WordPress on every build (or every request for SSR), this matters.
Custom table storage feature →
Field Forge auto-generates .d.ts TypeScript definitions for every field group. Download the file, commit it to your frontend repo, get full type safety:
import type { HeroSectionFields } from '@/types/fieldforge';
function Hero({ data }: { data: HeroSectionFields }) {
return <h1>{data.hero_title}</h1>; // TypeScript autocomplete + error checking
}
Types update automatically when you edit field groups. Re-run the TypeScript export to sync.
TypeScript generation feature →
When Field Forge and WPGraphQL are both active, Field Forge auto-registers GraphQL types for every field group on activation. Zero configuration. Custom fields become queryable immediately:
query GetPage {
page(id: "home", idType: URI) {
title
fieldforge {
heroSection {
title
subtitle
backgroundImage { sourceUrl }
}
}
}
}
No separate WPGraphQL for ACF plugin needed.
Field Forge exposes custom field values on core REST endpoints under a fieldforge property:
GET /wp-json/wp/v2/posts/123
{
"id": 123,
"title": { "rendered": "Our Services" },
"fieldforge": {
"service_list": [
{ "name": "Web Design", "description": "..." },
{ "name": "SEO", "description": "..." }
]
}
}
One API call returns the post + all custom fields. No N+1 problem.
Field Forge’s Local JSON Sync saves field groups as JSON files in your theme directory. Commit to git. Deploy across environments. Dev, staging, and production always have the same schema.
Here’s a complete setup guide for Next.js with Field Forge.
npx create-next-app@latest my-headless-site --typescript --app
cd my-headless-site
Create lib/wordpress.ts:
const WP_API_URL = process.env.WP_API_URL || 'https://wp.example.com/wp-json';
export async function fetchPost(slug: string) {
const res = await fetch(`${WP_API_URL}/wp/v2/posts?slug=${slug}`);
const posts = await res.json();
return posts[0];
}
export async function fetchPage(slug: string) {
const res = await fetch(`${WP_API_URL}/wp/v2/pages?slug=${slug}`);
const pages = await res.json();
return pages[0];
}
Download .d.ts file from Field Forge admin: Field Forge → Tools → TypeScript Export → Download.
Save to types/fieldforge.d.ts:
export interface WPImage {
id: number;
url: string;
alt: string;
sizes: {
thumbnail: string;
medium: string;
large: string;
full: string;
};
}
export interface HeroSectionFields {
title: string;
subtitle: string;
background_image: WPImage;
cta_button: {
text: string;
url: string;
};
}
export interface PageWithFields {
id: number;
title: { rendered: string };
fieldforge: {
hero_section?: HeroSectionFields;
};
}
app/[slug]/page.tsx:
import { fetchPage } from '@/lib/wordpress';
import type { PageWithFields, HeroSectionFields } from '@/types/fieldforge';
export default async function Page({ params }: { params: { slug: string } }) {
const page: PageWithFields = await fetchPage(params.slug);
return (
<main>
<h1>{page.title.rendered}</h1>
{page.fieldforge.hero_section && (
<Hero data={page.fieldforge.hero_section} />
)}
</main>
);
}
function Hero({ data }: { data: HeroSectionFields }) {
return (
<section className="hero">
<img
src={data.background_image.url}
alt={data.background_image.alt}
/>
<h2>{data.title}</h2>
<p>{data.subtitle}</p>
<a href={data.cta_button.url}>{data.cta_button.text}</a>
</section>
);
}
That’s a minimal Next.js + Field Forge setup. TypeScript autocomplete works. Refactoring field names is safe. Building fails if you typo a property.
generateStaticParamsFor static site generation (fastest option), fetch all pages at build time:
export async function generateStaticParams() {
const res = await fetch(`${WP_API_URL}/wp/v2/pages`);
const pages = await res.json();
return pages.map((page: any) => ({
slug: page.slug,
}));
}
Next.js will pre-render every page as static HTML at build time.
Astro is excellent for content sites with headless WordPress.
npm create astro@latest my-site
cd my-site
Create src/lib/wordpress.ts:
const WP_API_URL = import.meta.env.WP_API_URL;
export async function getPages() {
const res = await fetch(`${WP_API_URL}/wp/v2/pages`);
return res.json();
}
export async function getPageBySlug(slug: string) {
const res = await fetch(`${WP_API_URL}/wp/v2/pages?slug=${slug}`);
const [page] = await res.json();
return page;
}
src/pages/[slug].astro:
---
import { getPages, getPageBySlug } from '@/lib/wordpress';
import type { PageWithFields } from '@/types/fieldforge';
import Hero from '@/components/Hero.astro';
export async function getStaticPaths() {
const pages = await getPages();
return pages.map((page: any) => ({
params: { slug: page.slug },
props: { page },
}));
}
const { page } = Astro.props as { page: PageWithFields };
---
<html>
<body>
<h1 set:html={page.title.rendered} />
{page.fieldforge.hero_section && (
<Hero data={page.fieldforge.hero_section} />
)}
</body>
</html>
Astro’s static generation works well with Field Forge’s REST API — one build, all content fetched, static HTML output.
For Vue developers, Nuxt is the go-to WordPress headless framework.
npx nuxi@latest init my-site
cd my-site
server/api/page/[slug].get.ts:
export default defineEventHandler(async (event) => {
const { slug } = getRouterParams(event);
const config = useRuntimeConfig();
const response = await $fetch(
`${config.wpApiUrl}/wp/v2/pages?slug=${slug}`
);
return response[0];
});
pages/[slug].vue:
<template>
<main v-if="page">
<h1 v-html="page.title.rendered"></h1>
<Hero v-if="page.fieldforge.hero_section" :data="page.fieldforge.hero_section" />
</main>
</template>
<script setup lang="ts">
import type { PageWithFields } from '@/types/fieldforge';
const route = useRoute();
const { data: page } = await useFetch<PageWithFields>(`/api/page/${route.params.slug}`);
</script>
For Svelte fans:
// src/routes/[slug]/+page.server.ts
import type { PageServerLoad } from './$types';
import type { PageWithFields } from '$lib/types/fieldforge';
export const load: PageServerLoad = async ({ params, fetch }) => {
const res = await fetch(`${WP_API_URL}/wp/v2/pages?slug=${params.slug}`);
const pages = await res.json();
const page: PageWithFields = pages[0];
return { page };
};
<!-- src/routes/[slug]/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types';
import Hero from '$lib/components/Hero.svelte';
export let data: PageData;
</script>
<h1>{@html data.page.title.rendered}</h1>
{#if data.page.fieldforge.hero_section}
<Hero data={data.page.fieldforge.hero_section} />
{/if}
For sites needing deep nested queries, GraphQL is more efficient than REST:
async function fetchHomepage() {
const query = `
query GetHomepage {
page(id: "home", idType: URI) {
title
fieldforge {
heroSection {
title
subtitle
backgroundImage {
sourceUrl
altText
}
ctaButton {
text
url
}
}
}
}
}
`;
const res = await fetch(`${WP_API_URL}/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
});
const { data } = await res.json();
return data.page;
}
One request fetches the entire page including custom fields. No N+1 problem.
Should I use REST or GraphQL with headless WordPress?
Depends on the frontend and complexity. REST is simpler to set up. GraphQL is more efficient for deeply nested queries and reduces over-fetching. For small sites, REST. For sites with complex content structures, GraphQL.
How do I handle previews in headless WordPress?
Preview is one of the hardest parts of headless. Options: Next.js preview mode (fetches draft content with a secret token), Gatsby’s preview plugin, or custom WordPress → frontend webhook triggers.
How often should I rebuild my static site?
Depends on content update frequency. For daily-updated content, nightly builds. For less frequent updates, webhook-triggered builds (WordPress publishes a post → webhook triggers a build).
Can I edit content through the headless frontend?
Yes, via the WordPress REST API or WPGraphQL mutations. But for editorial workflows, most teams edit in WordPress admin and let the frontend read.
How do I handle forms in headless WordPress?
Options: 1) Form Forge posts to WordPress REST API, 2) Third-party form service (Formspree, Netlify Forms), 3) Direct API integration with form services. For most cases, Form Forge + REST API is the simplest.
What about media uploads?
Headless frontends typically still use WordPress’s media library for storage. The frontend fetches image URLs from API responses. For user-uploaded content, POST to WordPress’s media REST endpoint.
Get Field Forge — from $35/year →
Free version includes 24 field types and the visual builder. Paid plans unlock TypeScript auto-generation, GraphQL auto-registration, REST API fields, and the rest of the headless tooling.