wp-fetch
v0.1.0
Published
Framework-agnostic TypeScript client for the WordPress REST API, with a Next.js cache adapter.
Maintainers
Readme
wp-fetch
A framework-agnostic TypeScript client for the WordPress REST API, with an optional Next.js cache adapter.
- Zero runtime dependencies. The core imports nothing — not even
next. - Correct pagination. Handles WordPress answering HTTP 400 past the last page, and a keyset iterator that stays fast at 50,000 posts.
- Full-fidelity relations. Batch hydration returns complete authors and media, which
_embedcannot. - Deterministic cache tags you can hand to
next: { tags },cacheTag(), or any other cache. - Honest types.
totalis nullable because the header really can be missing; embedded authors have nodescriptionbecause WordPress really doesn't send one.
npm install wp-fetchRequires Node 22.12+ (ESM-only). Next.js 16+ is an optional peer dependency, needed only
for the wp-fetch/next subpaths.
Quick start
import { createWPClient } from "wp-fetch";
const wp = createWPClient({ url: "https://cms.example.com" });
const posts = await wp.posts.list({ perPage: 12 });
const post = await wp.posts.bySlug("hello-world");
const categories = await wp.categories.list({ hideEmpty: true });url accepts anything pasteable — example.com, a subdirectory install, or a full
https://example.com/blog/wp-json/wp/v2/posts URL. It is normalized to the site root.
Fetching
Every resource — posts, pages, categories, tags, users, media, comments,
search, plus wp.type() for custom post types — exposes the same surface:
await wp.posts.list({ perPage: 10 }); // one page → WPList<WPPost>
await wp.posts.get(42); // throws WPNotFoundError if absent
await wp.posts.find(42); // returns null instead
await wp.posts.bySlug("hello-world"); // exact indexed lookup, null if absent
await wp.posts.byIds([7, 2, 5]); // chunked at 100, YOUR order preserved
await wp.posts.count(); // number | null
for await (const post of wp.posts.all()) { … } // offset iteration
for await (const post of wp.posts.stream()) { … } // keyset iteration
wp.posts.tagsFor({ perPage: 10 }); // pure — no I/O
wp.posts.urlFor({ perPage: 10 }); // purePrefer listWithRelations over _embed
_embed looks like the obvious way to get a post's author and featured image. It has two
problems, and this is the single most useful thing to know about this library:
- Reduced fidelity. Embedded resources come back in
context=embed, a smaller field set — an embedded author has nodescription(bio) and nometa. - Duplication.
_embedinlines the same author object once per post. Ten posts by one author means ten copies.
listWithRelations instead collects the union of author / media / term IDs across the
page and issues one ?include= request per relation:
const posts = await wp.posts.listWithRelations(
{ perPage: 12 },
{ author: true, featuredMedia: true, categories: true },
);
posts.items[0].author?.name; // WPUser | null — full context=view object
posts.items[0].author?.description; // present, unlike with _embed
posts.items[0].featuredMedia; // WPMedia | null (featured_media: 0 → null)
posts.items[0].categories; // readonly WPTerm[]That is 1 + 3 requests for any page size, and fewer bytes than _embed. _embed is still
available via { embed: true } when one round trip matters more than fidelity; use
embeddedAuthor(), embeddedFeaturedMedia() and embeddedTerms() to read the result.
Pagination
all() walks pages by offset. It terminates on all three real end conditions, including
WordPress answering HTTP 400 — not an empty array — past the last page:
for await (const post of wp.posts.all({ perPage: 100 })) { … }stream() walks a date cursor instead and never sends page, so the SQL offset stays
at zero. Use it for generateStaticParams, exports, and anything touching more than a few
thousand posts:
for await (const post of wp.posts.stream({ fields: ["slug", "id", "date", "modified"] })) {
slugs.push(post.slug);
}For incremental sync, walk modified and persist the cursor:
let cursor;
for await (const post of wp.posts.stream({
cursor: "modified",
since: lastRun,
onCursor: (c) => {
cursor = c;
},
})) {
await upsert(post);
}
totalandtotalPagesarenumber | null.X-WP-Totalis genuinely optional — WordPress omits it on some query paths and caching plugins strip it.hasMoreis derived from the item count, so it stays correct either way.
Custom post types
rest_base frequently differs from a post type's slug, so discover it rather than guess:
const types = await wp.discover.types();
types["case_study"].rest_base; // → "case-studies"
const caseStudies = wp.type("case-studies");
await caseStudies.listWithRelations({ perPage: 10 }, { author: true });Register the type once to get inference without a generic at every call site:
declare module "wp-fetch" {
interface WPTypeRegistry {
"case-studies": CaseStudy;
}
}Plugin fields
Response types carry no index signature — that would defeat excess-property checking and
let post.titel type as unknown. Declare what your site adds:
declare module "wp-fetch" {
interface WPPostExtensions {
reading_time: number;
yoast_head_json: { title: string };
}
}Unknown fields are never stripped; reach them with getField(post, "some_key", isNumber).
Errors
import { isWPError } from "wp-fetch";
try {
await wp.posts.get(42);
} catch (error) {
if (!isWPError(error)) throw error;
switch (error.kind) {
case "not_found":
return null;
case "rate_limit":
case "timeout":
return cached();
default:
throw error;
}
}Switch on error.kind, not instanceof — instanceof breaks silently when a monorepo
ends up with two copies of the package.
Retries apply to 429, 5xx, network errors and timeouts, with exponential backoff, full
jitter, and Retry-After honoured (clamped, so a hostile header can't stall a build). A
caller's AbortSignal produces WPAbortError and is never retried; a timeout produces
WPTimeoutError and is.
createWPClient({
url,
timeoutMs: 10_000, // Node's fetch has NO default timeout
deadlineMs: 30_000, // wall-clock budget across all retries
retry: { attempts: 3, baseDelayMs: 300 },
});Next.js
Default caching
// lib/wp.ts
import { createWPClient } from "wp-fetch";
import { withNextCache } from "wp-fetch/next";
export const wp = withNextCache(createWPClient({ url: process.env.WORDPRESS_URL! }), {
mode: "fetch-cache",
revalidate: 3600,
});Every request now carries next: { revalidate, tags }, with the tags deduplicated and
capped to Next's limits (128 tags, 256 characters each).
Cache Components
Under cacheComponents: true, use { mode: "cache-components" }. The client then attaches
no fetch options — your "use cache" body is the cache boundary, and a per-fetch
revalidate would fight it. You lose nothing, because the tags are pure:
import { cacheLife, cacheTag } from "next/cache";
async function recentPosts() {
"use cache: remote"; // plain "use cache" is in-memory and per-deployment
cacheLife("hours");
cacheTag(...wp.posts.tagsFor({ perPage: 10 })); // pure — computes without fetching
return (await wp.posts.list({ perPage: 10 })).items;
}Plain
"use cache"is in-memory, discarded on instance teardown and scoped to one deployment. For CMS content on serverless that barely caches at all — use"use cache: remote"or configure acacheHandler.
Publish-to-live revalidation
// app/api/revalidate/route.ts
import { createRevalidateHandler } from "wp-fetch/next/revalidate";
import { wp } from "@/lib/wp";
export const { POST, GET } = createRevalidateHandler({
secret: process.env.WP_REVALIDATE_SECRET!,
siteKey: wp.siteKey, // must match, or the purge silently hits nothing
});Then copy wordpress/wp-fetch-revalidate.php into
wp-content/mu-plugins/ and set three constants in wp-config.php:
define( 'WP_FETCH_ENDPOINT', 'https://site.example.com/api/revalidate' );
define( 'WP_FETCH_SECRET', '…' ); // must match the handler's `secret`
define( 'WP_FETCH_SITE_KEY', '…' ); // must match the client's `siteKey`Requests are HMAC-SHA256 signed over the raw body plus a timestamp, with a 5-minute tolerance window so a captured request can't be replayed. The plugin posts non-blocking, so a front-end that is down can never fail a publish.
revalidateTagmarks entries stale — it does not refetch them. Fresh content lands on the next request to an affected page. UseonRevalidateto warm pages yourself if you need immediacy.
Cache tags
| Tag | Meaning |
| ---------------------- | --------------------------- |
| wp | everything |
| wp:s:<siteKey> | one site |
| wp:t:<restBase> | a collection — wp:t:posts |
| wp:o:<restBase>:<id> | one object |
| wp:x:<tax>[:<id>] | a taxonomy, or one term |
| wp:u:<id> | one author |
| wp:l:<lang> | one language |
| wp:q:<hash> | one exact query |
Lists emit collection-level tags only, never one per item — per_page=100 would
otherwise exceed Next's 128-tag limit. That is safe because of one invariant the webhook
handler upholds: any change emitting an object tag also emits its collection tag.
Multilingual
Polylang and WPML return all languages mixed when the language filter is omitted. That looks like working code until a French post appears on an English page, so strict mode (on by default) turns it into an error before the request is sent:
import { createWPClient, polylang } from "wp-fetch";
const wp = createWPClient({
url,
language: { strategy: polylang(), default: "en" },
});
await wp.posts.list(); // ?lang=en
await wp.posts.list({ lang: "fr" }); // ?lang=fr
await wp.posts.list({ lang: "all" }); // deliberate opt-out, no error
const fr = wp.withLanguage("fr"); // a new client; the original is unchangedwpml() sends both wpml_language and Accept-Language, since there is no single WPML
standard and sending both is harmless. queryParam(), header(), combine() and none()
cover bespoke setups. Note appliesTo: Polylang filters posts, terms and media but not
users.
ACF
ACF's acf_format changes shapes dramatically — light returns relational fields as IDs,
standard as full objects. Declare the schema once with branded aliases and both views are
derived:
import type { AcfImage, AcfPostRefs } from "wp-fetch";
declare module "wp-fetch" {
interface AcfSchemas {
post: { hero: AcfImage | false; related: AcfPostRefs };
}
interface WPClientDefaults {
acfFormat: "standard";
}
}
const wp = createWPClient({ url, acf: { format: "standard" } });
const post = await wp.posts.get(1);
post.acf.hero; // AcfImageObject | false
post.acf.related; // readonly WPPost[]
const light = await wp.posts.get(1, { acfFormat: "light" });
light.acf.hero; // number | falseSchemas declared without brands resolve to themselves, so plain interfaces work fine — you just don't get the divergence checked.
Media
media_details.sizes is {} far more often than people expect (SVGs, and any original
below every registered threshold), and some plugins serialize the empty case as []. Both
are handled:
wp.media.bestSize(image, 800); // WPMediaSize | null — smallest ≥ 800px
wp.media.srcSet(image); // null rather than srcset=""Testing your integration
The default suite runs against a built-in mock WordPress server — no live site needed:
npm test # unit + Next adapter suites
npm run test:types # compile-only type assertionsAn opt-in suite runs against a real site, read-only, and skips visibly when unset:
WP_TEST_URL=https://cms.example.com npm run test:liveCompatibility
| | |
| ------------- | -------------------------------------------------------------- |
| WordPress | 5.7+ (tested against 7.x) |
| Node.js | 22.12+ |
| Next.js | 16+ (optional; only for the /next subpaths) |
| Module format | ESM-only — require(esm) works on all supported Node versions |
Not in v1
Draft/preview and authenticated reads (the auth config slot exists so adding them is a
minor, not a breaking change), and Yoast/RankMath helpers — yoast_head_json already
round-trips today via the augmentation seam.
License
MIT
