-
Notifications
You must be signed in to change notification settings - Fork 0
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.
- 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)
| 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 |
-
Install the package:
pnpm add @stratawp/headless
-
To use the React hooks, install them alongside the package (this is the install line the package documents —
swrandreactship 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
-
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.
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',
},
})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: trueto include related resources (featured media, author, terms) under each post's_embeddedkey in a single request.
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(andgetPageBySlug) resolve to the object ornullwhen nothing matches the slug. The numericgetPost(id)/getPage(id)forms resolve to the object directly.
| 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.
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')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
datais the client response. BecausegetPosts/getPagesresolve to{ data, headers }, the array of items is atdata.data(i.e.result.data?.data).
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,
})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>
)
}| 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>
}The @stratawp/headless/next subpath provides static-generation and revalidation helpers; preview and SEO helpers come from the base @stratawp/headless entry.
// 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!,
},
})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>
)
}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
getAllPagesandgeneratePageParamshelpers exist for pages.
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
revalidateTagandrevalidatePathare server-only — they warn and no-op if called in the browser, and delegate tonext/cacheunder the hood.
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
getPreviewPostrequests the post withstatus: ['publish', 'draft', 'pending', 'future', 'private'], so the client must be authenticated for draft statuses to be returned. A matchinggetPreviewPagehelper is available for pages.
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)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)}
/>
)
}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 }).
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.
# .env.local
WORDPRESS_URL=https://your-wordpress-site.com
WORDPRESS_AUTH_USERNAME=admin
WORDPRESS_AUTH_PASSWORD=xxxx xxxx xxxx xxxx
PREVIEW_SECRET=your-preview-secretAll WordPress REST API shapes are exported as types:
import type {
WPPost,
WPPage,
WPCategory,
WPTag,
WPUser,
WPMedia,
WPQueryParams,
WPResponse,
} from '@stratawp/headless'| 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. |
Go headless. Build modern, decoupled WordPress applications with StrataWP. Full package reference: packages/headless/README.md.
StrataWP v2.0.0 · GPL-3.0-or-later · Built by Jon Imms Repository · README
Start here
Building themes
Shipping
Extending & contributing
Help