npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

wp-fetch

v0.1.0

Published

Framework-agnostic TypeScript client for the WordPress REST API, with a Next.js cache adapter.

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 _embed cannot.
  • Deterministic cache tags you can hand to next: { tags }, cacheTag(), or any other cache.
  • Honest types. total is nullable because the header really can be missing; embedded authors have no description because WordPress really doesn't send one.
npm install wp-fetch

Requires 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 });        // pure

Prefer 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:

  1. Reduced fidelity. Embedded resources come back in context=embed, a smaller field set — an embedded author has no description (bio) and no meta.
  2. Duplication. _embed inlines 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);
}

total and totalPages are number | null. X-WP-Total is genuinely optional — WordPress omits it on some query paths and caching plugins strip it. hasMore is 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 a cacheHandler.

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.

revalidateTag marks entries stale — it does not refetch them. Fresh content lands on the next request to an affected page. Use onRevalidate to 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 unchanged

wpml() 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 | false

Schemas 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 assertions

An 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:live

Compatibility

| | | | ------------- | -------------------------------------------------------------- | | 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