Skip to content

Headless WordPress

Jon Imms edited this page Jun 25, 2026 · 1 revision

Headless WordPress

Use WordPress as a content API and build your front-end with TypeScript, React, and Next.js using @stratawp/headless.

@stratawp/headless is the StrataWP package for decoupled ("headless") WordPress. It provides a typed REST API client, SWR-powered React hooks, and Next.js helpers for static generation, ISR, preview mode, and on-demand revalidation. This page walks through installation and the most common tasks with complete, copy-pasteable code.

Note This page covers the front-end consumer package. For the rest of the framework, see Core Concepts and Architecture & Packages. The full package reference lives at packages/headless/README.md.

Prerequisites

  • Node.js 18.18 or higher
  • A WordPress site with the REST API reachable (it is enabled by default in WordPress)
  • TypeScript (recommended — the package is TypeScript-first)
  • For the hooks: React (the package targets React 18)
  • For the Next.js integration: Next.js 13 or higher (the examples use the App Router)

What you get

Capability Summary
REST API Client Fully-typed WordPressClient with auth support
React Hooks SWR-powered hooks (usePosts, usePost, …)
Next.js Integration Static generation, ISR, preview mode
Authentication Basic Auth, JWT, Application Passwords, OAuth
SEO Utilities Generate metadata for posts and pages
Image Optimization Responsive images and Next.js Image integration
Preview Mode View draft content
Revalidation On-demand revalidation with tags and paths

1. Install

  1. Install the package:

    pnpm add @stratawp/headless
  2. To use the React hooks, install them alongside the package (this is the install line the package documents — swr and react ship as dependencies and are pulled in for you, but adding them explicitly keeps your front-end's React version pinned):

    pnpm add @stratawp/headless swr react
  3. To use the Next.js integration, add next:

    pnpm add @stratawp/headless next

Tip The package exposes subpath entries: import the core client and utilities from @stratawp/headless, the hooks from @stratawp/headless/react, and the Next.js helpers from @stratawp/headless/next.

2. Create a client

Create a single WordPressClient instance and reuse it across your app.

import { WordPressClient } from '@stratawp/headless'

const client = new WordPressClient({
  baseUrl: 'https://your-wordpress-site.com',
})

For protected content or authenticated writes, pass an auth object (see Authentication below):

const client = new WordPressClient({
  baseUrl: 'https://your-wordpress-site.com',
  auth: {
    type: 'application-password',
    username: 'your-username',
    password: 'your-application-password',
  },
})

3. Use the REST API client

Fetch a list of posts

getPosts() returns an object with data (the posts) and headers. Use query params like per_page, page, and _embed.

import { WordPressClient } from '@stratawp/headless'

const client = new WordPressClient({
  baseUrl: 'https://your-wordpress-site.com',
})

const { data: posts, headers } = await client.getPosts({
  per_page: 10,
  page: 1,
  _embed: true,
})

for (const post of posts) {
  console.log(post.title.rendered)
}

Tip Add _embed: true to include related resources (featured media, author, terms) under each post's _embedded key in a single request.

Fetch a single post

You can fetch by numeric ID or by slug:

// By ID
const post = await client.getPost(123)

// By slug
const post = await client.getPostBySlug('hello-world')

Note getPostBySlug (and getPageBySlug) resolve to the object or null when nothing matches the slug. The numeric getPost(id)/getPage(id) forms resolve to the object directly.

Other resource methods

Resource List Single
Posts getPosts(params?) getPost(id), getPostBySlug(slug)
Pages getPages(params?) getPage(id), getPageBySlug(slug)
Categories getCategories(params?) getCategory(id)
Tags getTags(params?) getTag(id)
Users getUsers(params?) getUser(id)
Media getMedia(params?) getMediaItem(id)

List methods (getPosts, getPages, getCategories, getTags, getUsers, getMedia) resolve to { data, headers }. Single-item methods resolve to the object directly.

Generic requests

For custom endpoints or write operations, use the generic verbs:

// Custom GET request (typed)
const data = await client.get<CustomType>('custom-endpoint')

// POST request
const result = await client.post('custom-endpoint', { field: 'value' })

// PUT request
const updated = await client.put('custom-endpoint/123', { field: 'new-value' })

// DELETE request
await client.delete('custom-endpoint/123')

4. Use the React hooks

The hooks are SWR-powered, so you also get error, isLoading, and mutate. Pass your shared client to each hook.

Note List hooks return SWR state where data is the client response. Because getPosts/getPages resolve to { data, headers }, the array of items is at data.data (i.e. result.data?.data).

Use a hook in a list component

import { usePosts } from '@stratawp/headless/react'
import { WordPressClient } from '@stratawp/headless'

const client = new WordPressClient({
  baseUrl: 'https://your-wordpress-site.com',
})

function BlogIndex() {
  const { data, error, isLoading } = usePosts({
    client,
    params: { per_page: 10, _embed: true },
  })

  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error loading posts</div>

  return (
    <div>
      {data?.data.map((post) => (
        <article key={post.id}>
          <h2>{post.title.rendered}</h2>
          <div dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }} />
        </article>
      ))}
    </div>
  )
}

You can pass SWR options inline alongside client and params:

const { data, error, isLoading, mutate } = usePosts({
  client,
  params: { per_page: 10, _embed: true },
  // SWR options
  revalidateOnFocus: false,
  refreshInterval: 60000,
})

Use a hook for a single post

usePost accepts either an id or a slug (passing neither throws). The example below fetches by slug:

import { usePost } from '@stratawp/headless/react'

function BlogPost({ slug }: { slug: string }) {
  const {
    data: post,
    error,
    isLoading,
  } = usePost({
    client,
    slug,
    params: { _embed: true },
  })

  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error loading post</div>
  if (!post) return <div>Post not found</div>

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

Available hooks

Hook Import Purpose
usePosts @stratawp/headless/react List posts
usePost @stratawp/headless/react Single post (by id or slug)
usePages @stratawp/headless/react List pages
usePage @stratawp/headless/react Single page (by id or slug)
useCategories @stratawp/headless/react List categories
useCategory @stratawp/headless/react Single category (by id)
import { usePages, usePage } from '@stratawp/headless/react'
import { useCategories, useCategory } from '@stratawp/headless/react'

function PageDetail({ slug }: { slug: string }) {
  const { data: page } = usePage({ client, slug })
  return <article>{page?.title.rendered}</article>
}

function CategoryDetail({ id }: { id: number }) {
  const { data: category } = useCategory({ client, id })
  return <div>{category?.name}</div>
}

5. Wire up Next.js (App Router)

The @stratawp/headless/next subpath provides static-generation and revalidation helpers; preview and SEO helpers come from the base @stratawp/headless entry.

Step 1 — Centralize the client

// lib/wordpress.ts
import { WordPressClient } from '@stratawp/headless'

export const wordpress = new WordPressClient({
  baseUrl: process.env.WORDPRESS_URL!,
  auth: {
    type: 'application-password',
    username: process.env.WORDPRESS_AUTH_USERNAME!,
    password: process.env.WORDPRESS_AUTH_PASSWORD!,
  },
})

Step 2 — Render a list page with ISR

Set export const revalidate to enable Incremental Static Regeneration.

// app/blog/page.tsx
import { wordpress } from '@/lib/wordpress'

export const revalidate = 60 // Revalidate every 60 seconds

export default async function BlogPage() {
  const { data: posts } = await wordpress.getPosts({
    per_page: 10,
    _embed: true,
  })

  return (
    <div>
      <h1>Blog</h1>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title.rendered}</h2>
          <div dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }} />
          <a href={`/blog/${post.slug}`}>Read more</a>
        </article>
      ))}
    </div>
  )
}

Step 3 — Statically generate post pages

Use generatePostParams to build generateStaticParams for every post:

// app/blog/[slug]/page.tsx
import { WordPressClient } from '@stratawp/headless'
import { generatePostParams } from '@stratawp/headless/next'
import { notFound } from 'next/navigation'

const client = new WordPressClient({
  baseUrl: process.env.WORDPRESS_URL!,
})

export async function generateStaticParams() {
  return await generatePostParams(client)
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await client.getPostBySlug(params.slug, { _embed: true })

  if (!post) {
    notFound()
  }

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

Tip If you prefer to build params yourself, getAllPosts(client, params?) returns the full list of posts (it pages through the REST API in batches of 100):

import { getAllPosts } from '@stratawp/headless/next'

export async function generateStaticParams() {
  const posts = await getAllPosts(client)
  return posts.map((post) => ({ slug: post.slug }))
}

The matching getAllPages and generatePageParams helpers exist for pages.

Step 4 — On-demand revalidation

Re-render statically generated pages immediately when content changes, using revalidateTag or revalidatePath:

import { revalidateTag, revalidatePath } from '@stratawp/headless/next'

// In an API route or Server Action
export async function POST(request: Request) {
  const { tag } = await request.json()

  // Revalidate by tag
  revalidateTag(tag)

  // Or revalidate by path
  revalidatePath('/blog')

  return Response.json({ revalidated: true })
}

Note revalidateTag and revalidatePath are server-only — they warn and no-op if called in the browser, and delegate to next/cache under the hood.

Preview mode (draft content)

Use verifyPreviewSecret and getPreviewPost (from @stratawp/headless) to safely render drafts behind a shared secret.

// app/api/preview/route.ts
import { verifyPreviewSecret, getPreviewPost } from '@stratawp/headless'

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const secret = searchParams.get('secret')
  const id = searchParams.get('id')

  if (!secret || !id) {
    return Response.json({ message: 'Missing params' }, { status: 401 })
  }

  if (!verifyPreviewSecret(secret, process.env.PREVIEW_SECRET!)) {
    return Response.json({ message: 'Invalid secret' }, { status: 401 })
  }

  const post = await getPreviewPost(client, Number(id))

  if (!post) {
    return Response.json({ message: 'Post not found' }, { status: 404 })
  }

  // Enable preview mode
  const response = Response.redirect(new URL(`/blog/${post.slug}`, request.url))
  response.cookies.set('__next_preview_data', JSON.stringify({ id, type: 'post' }))

  return response
}

Note getPreviewPost requests the post with status: ['publish', 'draft', 'pending', 'future', 'private'], so the client must be authenticated for draft statuses to be returned. A matching getPreviewPage helper is available for pages.

SEO metadata

Generate Next.js Metadata from a post with generatePostSEO (or generatePageSEO for pages):

import { generatePostSEO } from '@stratawp/headless'
import type { Metadata } from 'next'

export async function generateMetadata({
  params,
}: {
  params: { slug: string }
}): Promise<Metadata> {
  const post = await client.getPostBySlug(params.slug, { _embed: true })

  if (!post) return {}

  const featuredMedia = post._embedded?.['wp:featuredmedia']?.[0]
  const seo = generatePostSEO(post, 'https://your-site.com', 'Your Site Name', featuredMedia)

  return {
    title: seo.title,
    description: seo.description,
    openGraph: {
      title: seo.openGraph?.title,
      description: seo.openGraph?.description,
      url: seo.openGraph?.url,
      images: seo.openGraph?.image ? [seo.openGraph.image] : [],
      type: seo.openGraph?.type,
    },
    twitter: {
      card: seo.twitter?.card,
      title: seo.twitter?.title,
      description: seo.twitter?.description,
      images: seo.twitter?.image ? [seo.twitter.image] : [],
    },
  }
}

For plain text excerpts, use extractExcerpt:

import { extractExcerpt } from '@stratawp/headless'

const excerpt = extractExcerpt(post.excerpt.rendered, 160)

Image utilities

Responsive <img>

import { getImageSrcSet, getImageSizes, getImageAlt } from '@stratawp/headless'

function PostImage({ media }: { media: WPMedia }) {
  return (
    <img
      src={media.source_url}
      srcSet={getImageSrcSet(media)}
      sizes={getImageSizes(800)}
      alt={getImageAlt(media)}
    />
  )
}

Next.js Image

import Image from 'next/image'
import { getNextImageProps } from '@stratawp/headless'

function PostFeaturedImage({ media }: { media: WPMedia }) {
  const imageProps = getNextImageProps(media, {
    width: 1200,
    height: 630,
    quality: 85,
  })

  return <Image {...imageProps} />
}

You can also build a single optimized URL with getOptimizedImageUrl(media, { width, height }).

Authentication

Pass an auth object to the client. Four type values are supported:

type Required fields Notes
basic username, password Basic Auth
application-password username, password Recommended; create under WordPress → Users → Application Passwords
jwt token Bring your own JWT (sent as a Bearer token)
oauth token Bring your own OAuth token (sent as a Bearer token)
// Application Passwords (recommended)
const client = new WordPressClient({
  baseUrl: 'https://your-site.com',
  auth: {
    type: 'application-password',
    username: 'admin',
    password: 'xxxx xxxx xxxx xxxx', // From WordPress Application Passwords
  },
})

// JWT
const client = new WordPressClient({
  baseUrl: 'https://your-site.com',
  auth: { type: 'jwt', token: 'your-jwt-token' },
})

Warning Never hard-code credentials. Read them from environment variables (see below) and keep secrets out of client-side bundles — authenticate only in server components, route handlers, or server actions.

Environment variables

# .env.local
WORDPRESS_URL=https://your-wordpress-site.com
WORDPRESS_AUTH_USERNAME=admin
WORDPRESS_AUTH_PASSWORD=xxxx xxxx xxxx xxxx
PREVIEW_SECRET=your-preview-secret

TypeScript types

All WordPress REST API shapes are exported as types:

import type {
  WPPost,
  WPPage,
  WPCategory,
  WPTag,
  WPUser,
  WPMedia,
  WPQueryParams,
  WPResponse,
} from '@stratawp/headless'

Troubleshooting

Symptom Likely cause / fix
Empty list from a hook Remember items are at result.data?.data, since getPosts/getPages resolve to { data, headers }.
_embedded is undefined Add _embed: true to your params.
401 on protected content or writes Check your auth block; for Application Passwords, confirm the password is enabled for that user.
Drafts not returned in preview getPreviewPost/getPreviewPage need an authenticated client to read non-publish statuses.
Preview returns "Invalid secret" Ensure the secret query param matches process.env.PREVIEW_SECRET.
usePost/usePage throws at call time Pass either an id or a slug — the hook throws if both are omitted.
Hooks fail to resolve their dependencies Install the hook deps: pnpm add swr react.

Related pages


Go headless. Build modern, decoupled WordPress applications with StrataWP. Full package reference: packages/headless/README.md.

Clone this wiki locally