The Ultimate Guide to Headless WordPress

headless wordpress

The Ultimate Guide to Headless WordPress (Setup, Code Examples & Best Practices)

Headless WordPress separates WordPress into two parts: content management (admin, database, APIs) and a completely separate frontend rendered by a modern framework (like Next.js, Nuxt, Remix, or SvelteKit). In this guide, we’ll define what “headless” means, compare REST vs GraphQL, walk through a realistic Next.js setup, share production gotchas, and give you a maintenance playbook so your stack stays fast and sane.


Quick Start Checklist

  • Install WordPress and confirm the REST API works at /wp-json
  • (Optional) Install WPGraphQL for GraphQL queries
  • Create a Next.js app and fetch content with getStaticProps()
  • Use environment variables for API URLs and keys
  • Add caching (ISR, CDN) and security (CORS, auth)
  • Deploy WordPress + the frontend (e.g., Kinsta/Flywheel + Vercel)

What Is Headless WordPress?

Traditional WordPress renders your theme’s PHP templates on the server. Headless WordPress, instead, exposes your content via APIs (REST or GraphQL) and lets a separate application render the UI. WordPress becomes a content hub; your frontend is just another consumer.

How Headless WordPress Works (REST vs GraphQL)

REST API ships with WordPress core and returns JSON from endpoints like /wp-json/wp/v2/posts. It’s battle-tested, great for cachin

GraphQL (via WPGraphQL) adds a single endpoint where clients request exactly the fields they need. This can reduce payload size and over-fetching, and feels great in React/Next.js apps.

  • Use REST if you want zero additional plugins, super-simple caching, or you’re already familiar with REST tooling.
  • Use GraphQL if you want precise queries, nested relationships in one request, or a strongly-typed schema.

Pros & Cons

ProsCons
Modern UX & performance with frameworks like Next.jsArchitecturally more complex than classic themes
Scale backends & frontends independentlyTwo deploys to manage (CMS + app)
Multichannel: web, mobile, kiosks, etc. from one CMSExtra work to support previews, search, and auth
Developer experience (components, SSR/ISR, APIs)Requires API familiarity (REST or GraphQL)

When Headless Makes Sense

  • Yes: large content sites, design systems, apps needing reactive UX, multichannel delivery, or heavy traffic that benefits from CDNs + ISR.
  • Maybe: marketing sites that want SPA feel, long-lived microsites with shared components.
  • No: small brochure sites where classic themes are faster to build and easier to maintain.

Step-by-Step: Headless WordPress with Next.js

1) Prepare WordPress

  • Go to Settings → Permalinks and choose Post name.
  • Confirm REST works: https://your-wp-site.com/wp-json. You should see a JSON index.
  • (Optional) Add WPGraphQL (plugin) if you prefer GraphQL.
  • (Optional) Add Application Passwords (built into WordPress) for authenticated requests.

2) Create the Next.js app

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

Create a .env.local file (not checked into Git):

WP_API_URL=https://your-wp-site.com
# For GraphQL:
WP_GRAPHQL_URL=https://your-wp-site.com/graphql

3A) Fetch posts via REST

// pages/index.js
export async function getStaticProps() {
  const base = process.env.WP_API_URL;
  const res = await fetch(`${base}/wp-json/wp/v2/posts?per_page=10&_embed=1`);
  const posts = await res.json();

  return {
    props: { posts },
    revalidate: 60 // ISR: re-generate every 60s on traffic
  };
}

export default function Home({ posts }) {
  return (
    <main>
      <h1>Latest Posts</h1>
      {posts.map((p) => (
        <article key={p.id}>
          <h2 dangerouslySetInnerHTML={{ __html: p.title.rendered }} />
          <div dangerouslySetInnerHTML={{ __html: p.excerpt.rendered }} />
        </article>
      ))}
    </main>
  );
}

3B) Fetch posts via GraphQL (WPGraphQL)

// lib/wpgraphql.js
const WP_GRAPHQL_URL = process.env.WP_GRAPHQL_URL;

export async function gql(query, variables = {}) {
  const res = await fetch(WP_GRAPHQL_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables })
  });
  const json = await res.json();
  if (json.errors) throw new Error(JSON.stringify(json.errors));
  return json.data;
}

// pages/index.js
import { gql } from '../lib/wpgraphql';

const POSTS = `
  query LatestPosts($first: Int!) {
    posts(first: $first) {
      nodes {
        databaseId
        title
        excerpt
        date
        slug
      }
    }
  }
`;

export async function getStaticProps() {
  const data = await gql(POSTS, { first: 10 });
  return { props: { posts: data.posts.nodes }, revalidate: 60 };
}

export default function Home({ posts }) {
  return (
    <main>
      <h1>Latest Posts</h1>
      {posts.map(p => (
        <article key={p.databaseId}>
          <h2 dangerouslySetInnerHTML={{ __html: p.title }} />
          <div dangerouslySetInnerHTML={{ __html: p.excerpt }} />
        </article>
      ))}
    </main>
  );
}

4) Single posts & static paths

// pages/posts/[slug].js (REST version)
export async function getStaticPaths() {
  const res = await fetch(`${process.env.WP_API_URL}/wp-json/wp/v2/posts?per_page=50&_fields=slug`);
  const posts = await res.json();
  return {
    paths: posts.map(p => ({ params: { slug: p.slug } })),
    fallback: 'blocking'
  };
}

export async function getStaticProps({ params }) {
  const res = await fetch(`${process.env.WP_API_URL}/wp-json/wp/v2/posts?slug=${params.slug}&_embed=1`);
  const [post] = await res.json();
  if (!post) return { notFound: true };
  return { props: { post }, revalidate: 60 };
}

export default function Post({ post }) {
  return (
    <article>
      <h1 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
      <div dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
    </article>
  );
}

5) Media, menus, and custom fields

  • Images (REST): in _embedded['wp:featuredmedia'] or fetch /wp/v2/media?parent=ID.
  • Menus: use a nav menu plugin for REST/GraphQL exposure, or build a “menu” CPT and query it.
  • Custom fields: expose via REST using register_rest_field() or via WPGraphQL’s ACF integration.

💡 Tip: Use Incremental Static Regeneration (ISR)

Set revalidate on pages to auto-refresh content after a time window. It gives you static speed with near-real-time freshness.

Security, CORS & Authentication

For public content, read-only requests work without auth. For previews or private data, use Application Passwords, JWT, OAuth, or reverse proxies. Lock down CORS to only your frontend domain.

Enable strict CORS for REST

// functions.php (tighten CORS to your frontend)
add_action('rest_api_init', function () {
  remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');
  add_filter('rest_pre_serve_request', function ($value) {
    header('Access-Control-Allow-Origin: https://your-frontend.com');
    header('Access-Control-Allow-Methods: GET, OPTIONS');
    header('Access-Control-Allow-Headers: Authorization, Content-Type');
    header('Vary: Origin');
    return $value;
  });
}, 15);

Application Passwords (basic auth)

// Example: add Authorization header for authenticated fetch
const token = Buffer.from(`${process.env.WP_USER}:${process.env.WP_APP_PW}`).toString('base64');
const res = await fetch(`${process.env.WP_API_URL}/wp-json/wp/v2/posts`, {
  headers: { Authorization: `Basic ${token}` }
});

⚠️ Common Mistakes

  • Wildcard CORS (*) in production – lock it to your domain.
  • Hard-coding API URLs – always use .env.local.
  • Fetching too much data – paginate and select fields.
  • Skipping image optimization – use Next/Image or a CDN.

Performance: Caching & Revalidation

  • ISR (Next.js): use revalidate to refresh stale pages automatically.
  • CDN: cache HTML and assets at the edge (Vercel/Cloudflare/etc.).
  • WPGraphQL Smart Cache: cache GraphQL queries and invalidate on content changes.
  • Object cache: Redis/Memcached on your WP host to reduce DB load.

Previewing Drafts

For a true editorial experience, set a draft preview route in your Next.js app that verifies a secret and slug, then fetches draft content via REST or GraphQL with authentication.

// pages/api/preview.js (example)
export default async function handler(req, res) {
  const { secret, slug } = req.query;
  if (secret !== process.env.PREVIEW_SECRET) return res.status(401).end('Invalid token');

  // fetch the post by slug (may require auth)
  const postRes = await fetch(`${process.env.WP_API_URL}/wp-json/wp/v2/posts?slug=${slug}`);
  const [post] = await postRes.json();
  if (!post) return res.status(404).end('Not found');

  res.setPreviewData({});
  res.writeHead(307, { Location: `/posts/${post.slug}` });
  res.end();
}

Search Options

  • Client-side: simple filter of fetched lists (OK for small sites).
  • Server-side: query /wp/v2/search or GraphQL connections with arguments.
  • External search: Algolia, Meilisearch, or Elasticsearch for speed and ranking.

Maintenance & Operations

  • Updates: patch WordPress core/plugins and the frontend dependencies regularly.
  • Backups: database + uploads; test restores.
  • Monitoring: uptime checks for both the API and the frontend; observe error rates.
  • Deploys: use CI; keep .env secrets separate per environment.
  • Observability: log API latency, cache hit ratio, and build times.

When Headless Isn’t a Fit

If your site is small, rarely updated, and doesn’t need app-like interactivity, a classic theme is cheaper, simpler, and fast with good hosting and caching. Headless shines with scale, interactivity, and multichannel needs.


References & Further Learning

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *