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

@plank-cms/client

v0.7.4

Published

Client for the Plank CMS headless API.

Readme

Plank CMS - Client

Client for the Plank CMS headless API. Framework-agnostic and compatible with Next.js App Router, Astro, or any project with fetch.

Installation

pnpm add @plank-cms/client

Setup

Create a client instance and reuse it across your project:

// lib/plank.ts
import { createPlankClient } from "@plank-cms/client";

const plank = createPlankClient({
  url: process.env.PLANK_URL!,
  token: process.env.PLANK_TOKEN!,
  // defaultLocale: "en",
});

export default plank;
# .env.local
PLANK_URL=https://your-plank-instance.com
PLANK_TOKEN=plank_a1b2c3d4...

Usage

Collections

import plank from "@/lib/plank";

const { data, total, page, limit } = await plank.collection("posts").findMany();

Requests are fresh by default. The client uses cache: "no-store" unless you override it.

With params:

const { data } = await plank.collection("posts").findMany({
  page: 1,
  limit: 9,
  status: "published",
  category: "news",
});

Locale override:

const { data: esPosts } = await plank
  .collection("posts")
  .findMany({ locale: "es" });
const { data: frPosts } = await plank
  .collection("posts")
  .findMany({ locale: "fr" });
const post = await plank.collection("posts").findOne("entry-id");

With request params:

const localizedPost = await plank.collection("posts").findOne("entry-id", {
  status: "published",
  locale: "es",
  fallback: "en",
}, {
  cache: "no-store",
});

Single Types

const homepage = await plank.single("homepage").find();

With public API params:

const homepage = await plank.single("homepage").find({
  status: "published",
  locale: "es",
  fallback: "en",
});

Filtering and sorting

const { data } = await plank.collection("posts").findMany({
  status: "published",
  sort: "published_at",
  order: "desc",
  locale: "es",
  fallback: "en",
  filters: {
    category: { eq: "news" },
    featured: { eq: true },
  },
});

Field filters use filters[field][operator] semantics:

const { data } = await plank.collection("categories").findMany({
  filters: {
    slug: {
      in: ["design", "motion", "branding"],
      nin: ["internal", "archived"],
    },
  },
});

Low-level fetch works the same way:

const posts = await plank.fetch("/posts", {
  limit: 5,
  sort: "created_at",
  order: "desc",
  author: "alejandro-martir",
});

Authors

Fetch a public author profile by slug:

const author = await plank.fetch("/authors/alejandro-martir");

Filter any collection by public author slug:

const { data } = await plank.collection("posts").findMany({
  author: "alejandro-martir",
  status: "published",
});

Public entries may also include author.slug and editor.slug when those objects are present in the API response.

Build the public API URL without fetching:

const url = plank.buildUrl("/posts", {
  status: "published",
  sort: "published_at",
  order: "desc",
  category: "news",
});
// https://your-plank-instance.com/api/posts?status=published&sort=published_at&order=desc&category=news

Field selection

Use fields or select to include only specific top-level serialized fields:

const { data } = await plank.collection("posts").findMany({
  status: "published",
  fields: ["id", "title", "slug", "cover"],
});
const post = await plank.collection("posts").findOne("entry-id", {
  select: ["id", "title", "cover"],
});

Exclude specific top-level fields from the serialized response:

const { data } = await plank.collection("posts").findMany({
  status: "published",
  exclude: ["body", "author", "editor"],
});

Works for collections, single-entry fetches, and single types:

const post = await plank.collection("posts").findOne(
  "entry-id",
  {
    fields: ["id", "title", "cover", "published_at"],
  },
  { cache: "no-store" },
);

const homepage = await plank.single("homepage").find({
  exclude: ["updated_at", "editor"],
});

Notes:

  • fields, select, and exclude are top-level only.
  • select is an alias of fields.
  • Supported operators are eq, ne, in, and nin.

You can still narrow the response locally with TypeScript when useful:

type PostCard = {
  id: string;
  title: string;
  slug: string;
  cover: PlankMedia | null;
};

const { data } = await plank.collection<PostCard>("posts").findMany({
  status: "published",
  fields: ["id", "title", "slug", "cover"],
});

Drafts

const draft = await plank
  .collection("posts")
  .findOne("entry-id", { status: "draft" }, { cache: "no-store" });
// or with status
const drafts = await plank
  .collection("posts")
  .findMany({ status: "draft" }, { cache: "no-store" });

Draft preview sync webhook

The client only provides a type guard for the preview sync webhook payload. The frontend is responsible for handling the webhook, exposing a small polling endpoint, and reloading the preview tab.

In Plank, configure these preview settings:

  • Enable preview integration
  • Preview URL template
  • Preview sync webhook URL

After each entry save, Plank will POST a preview sync payload to your frontend webhook URL while preview is enabled.

Webhook payload:

type PlankPreviewSyncWebhookPayload = {
  event: "preview.sync";
  content_type: string;
  entry_id: string;
  status: string | null;
  slug: string | null;
  preview_url: string | null;
  triggered_at: string;
};

Validate it with:

import { isPlankPreviewSyncWebhookPayload } from "@plank-cms/client";

Route pattern:

/draft/[contentType]/[slug]

Flow:

  1. Plank opens /draft/[contentType]/[slug].
  2. Plank sends preview.sync to your webhook after each save.
  3. Your webhook stores the latest sync state in memory, keyed by contentType + slug.
  4. The preview page polls /api/plank/preview-state/[contentType]/[slug].
  5. The browser compares triggered_at with the last value in localStorage.
  6. If preview_url changed, navigate to it. Otherwise reload the page.

Next.js App Router example

Template in Plank:

https://frontend.example.com/draft/{contentType}/{slug}

Preview route:

import PreviewAutoRefresh from "@/components/PreviewAutoRefresh";
import plank from "@/lib/plank";
import { notFound } from "next/navigation";

export default async function DraftPage({
  params,
}: {
  params: Promise<{ contentType: string; slug: string }>;
}) {
  const { contentType, slug } = await params;

  const { data } = await plank.collection(contentType).findMany(
    {
      limit: 1,
      status: "all",
      filters: {
        slug: { eq: slug },
      },
    },
    { cache: "no-store" },
  );

  const post = data[0] ?? null;

  if (!post) notFound();

  return (
    <>
      <PreviewAutoRefresh contentType={contentType} slug={slug} />
      <article>{post.title}</article>
    </>
  );
}

In-memory sync store:

// lib/preview-sync-store.ts
export type PreviewSyncState = {
  previewUrl: string | null;
  triggeredAt: string;
};

const previewSyncStore = new Map<string, PreviewSyncState>();

export function buildPreviewSyncKey(contentType: string, slug: string) {
  return `${contentType}:${slug}`;
}

export async function setPreviewSyncState(
  contentType: string,
  slug: string,
  state: PreviewSyncState,
) {
  previewSyncStore.set(buildPreviewSyncKey(contentType, slug), state);
}

export async function getPreviewSyncState(contentType: string, slug: string) {
  return previewSyncStore.get(buildPreviewSyncKey(contentType, slug)) ?? null;
}

Webhook route:

import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import { isPlankPreviewSyncWebhookPayload } from "@plank-cms/client";
import { setPreviewSyncState } from "@/lib/preview-sync-store";

export async function POST(request: Request) {
  const body = await request.json().catch(() => null);

  if (!isPlankPreviewSyncWebhookPayload(body)) {
    return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
  }

  if (body.slug) {
    revalidatePath(`/draft/${body.content_type}/${body.slug}`);

    await setPreviewSyncState(body.content_type, body.slug, {
      previewUrl: body.preview_url,
      triggeredAt: body.triggered_at,
    });
  }

  return NextResponse.json({ ok: true });
}

Polling endpoint:

import { NextResponse } from "next/server";
import { getPreviewSyncState } from "@/lib/preview-sync-store";

export async function GET(
  _request: Request,
  context: { params: Promise<{ contentType: string; slug: string }> },
) {
  const { contentType, slug } = await context.params;
  const state = await getPreviewSyncState(contentType, slug);

  return NextResponse.json({
    triggeredAt: state?.triggeredAt ?? null,
    previewUrl: state?.previewUrl ?? null,
  });
}

Preview polling component:

// components/PreviewAutoRefresh.tsx
'use client';

import { useEffect } from "react";

export default function PreviewAutoRefresh({
  contentType,
  slug,
}: {
  contentType: string;
  slug: string;
}) {
  useEffect(() => {
    let cancelled = false;
    const storageKey = `plank-preview:${contentType}:${slug}`;

    const poll = async () => {
      try {
        const response = await fetch(
          `/api/plank/preview-state/${contentType}/${slug}`,
          { cache: "no-store" },
        );

        if (!response.ok) return;

        const state = (await response.json()) as {
          triggeredAt: string | null;
          previewUrl: string | null;
        };

        if (!state.triggeredAt) return;

        const lastTriggeredAt = window.localStorage.getItem(storageKey);

        if (!lastTriggeredAt) {
          window.localStorage.setItem(storageKey, state.triggeredAt);
          return;
        }

        if (state.triggeredAt === lastTriggeredAt) return;

        window.localStorage.setItem(storageKey, state.triggeredAt);

        if (state.previewUrl && state.previewUrl !== window.location.href) {
          window.location.assign(state.previewUrl);
          return;
        }

        window.location.reload();
      } catch {
        // Ignore transient polling failures.
      }
    };

    const interval = window.setInterval(() => {
      if (!cancelled) void poll();
    }, 2000);

    void poll();

    return () => {
      cancelled = true;
      window.clearInterval(interval);
    };
  }, [contentType, slug]);

  return null;
}

Notes:

  • Use /draft/[contentType]/[slug].
  • Fetch preview content with cache: "no-store" and status: "all".
  • Key preview sync state by both contentType and slug.
  • If preview_url changes after a save, navigate to it instead of only reloading.

Next.js App Router cache

Fresh by default

Every request uses cache: "no-store" unless you override it.

await plank.collection("posts").findMany();

Static / force-cache

await plank.collection("posts").findMany({}, { cache: "force-cache" });

ISR — Incremental Static Regeneration

Revalidate on a time interval:

// revalidate every 10 minutes
await plank.collection("posts").findMany({}, { revalidate: 600 });

// revalidate every 24 hours
await plank.single("homepage").find({}, { revalidate: 86400 });

No cache

await plank.collection("posts").findMany({}, { cache: "no-store" });

TypeScript

The client is fully typed. Pass your content type interface as a generic to get typed responses:

import type { PlankMedia } from "@plank-cms/client";

interface Post {
  id: string;
  title: string;
  slug: string;
  body: string;
  cover: PlankMedia;
  published_at: string;
}

const { data } = await plank.collection<Post>("posts").findMany();
// data is Post[]

const post = await plank.collection<Post>("posts").findOne("entry-id");
// post is Post

Images and galleries now resolve to rich media objects:

interface Homepage {
  hero: PlankMedia;
  gallery: PlankMedia[];
}

Framework support

The client is framework-agnostic and works anywhere standard fetch is available, including Next.js, Astro, Remix, SvelteKit, Node.js, or plain server-side JavaScript/TypeScript.


Query params reference

| Param | Type | Default | Description | | ------------- | --------------------------------- | ------------- | ------------------------------------------------------------- | | page | number | 1 | Page number | | limit | number | 20 | Entries per page (max 100) | | status | 'published' \| 'draft' \| 'all' | 'published' | Filter by status | | sort | string | — | Field name to sort by | | order | 'asc' \| 'desc' | — | Sort direction | | author | string | — | Filter collection entries by public author slug | | filters | PlankFilters | — | Field-based filters using operator objects | | locale | string | — | Request a localized version of localizable fields (e.g. es) | | fallback | string \| string[] | — | Comma-separated fallback locale list (e.g. en,fr) | | fields | string \| string[] | — | Include only specific top-level serialized fields | | select | string \| string[] | — | Alias of fields | | exclude | string \| string[] | — | Remove specific top-level serialized fields |


Low-level API

Use fetch and buildUrl directly when you need full control:

// raw fetch
const data = await plank.fetch("/posts", { limit: 5 }, { revalidate: 300 });

// build URL without fetching
const url = plank.buildUrl("/posts", { category: "news", limit: 10 });
// https://your-plank-instance.com/api/posts?category=news&limit=10

License

MIT - AM25, S.A.S. DE C.V.