@writbee/core
v0.2.1
Published
Typed client for the Writbee public content API. No dependencies, no framework.
Maintainers
Readme
@writbee/core
Typed client for the Writbee public content API. No dependencies, no framework, no environment assumptions — it runs in Node, Bun, Deno, a Cloudflare Worker, an Astro frontmatter block or an Express route.
If you are on Next.js, use @writbee/next instead: it wraps this
package with Next's cache and a guard that keeps your secret key out of the
browser.
npm install @writbee/coreUsage
import { createWritbeeClient } from "@writbee/core"
const writbee = createWritbeeClient({
baseUrl: "https://api.writbee.com/api",
apiKey: process.env.WRITBEE_SECRET_KEY!,
})
const posts = await writbee.getPosts()
const post = await writbee.findPost("hello-world")| Method | Returns |
| --- | --- |
| getPosts(options?) | The first page of posts, newest first |
| listPosts(options?) | { posts, nextCursor, prevCursor } — one page at a time |
| allPosts(options?) | An async iterator over every post |
| getPost(slug, options?) | One post, throwing not_found when there is none |
| findPost(slug, options?) | One post, or null |
getPost and findPost both exist because both cases are real: a post page
wants notFound() when the slug is gone, while a background job wants the
failure to ring. Every options argument takes an AbortSignal as signal.
The API wraps its responses in data. This package unwraps it, so that
convention never leaks into your code.
Paging
The list endpoint is paginated by cursor: 20 posts per page by default, 100 at
most. getPosts() hands you the first page, which is the whole feed for most
sites.
When it is not, listPosts exposes the cursor:
let page = await writbee.listPosts({ limit: 50 })
while (page.nextCursor) {
page = await writbee.listPosts({ limit: 50, cursor: page.nextCursor })
}...and allPosts walks it for you, one page in memory at a time — which is
what a sitemap or a full rebuild wants:
for await (const post of writbee.allPosts()) {
console.log(post.slug)
}prevCursor goes back, through that same cursor option — a cursor carries the
direction it points in, so there is nothing else to pass. It is null on the
first page, as nextCursor is on the last, which makes both of them the
condition for rendering a link:
{prevCursor && <Link href={`/blog?cursor=${prevCursor}`}>Newer</Link>}
{nextCursor && <Link href={`/blog?cursor=${nextCursor}`}>Older</Link>}A cursor points at the last post you were handed rather than at a page number, so a post published while you are paging cannot shift the pages under you or make one show up twice. The flip side is that there are no page numbers and no total count — you move one page at a time, in either direction.
Keys
A publishable (pk_) key is restricted to the domains you list in the
dashboard and is safe in a browser. A secret (sk_) key is not: it receives no
CORS header, and it belongs only on a server. Passing one while window exists
throws immediately, rather than shipping the key in your page source.
Errors
Every failure is a WritbeeError with a code you can branch on, so you never
have to parse a message:
import { WritbeeError } from "@writbee/core"
try {
await writbee.getPost(slug)
} catch (error) {
if (error instanceof WritbeeError && error.code === "not_found") {
// ...
}
}| code | Meaning |
| --- | --- |
| unauthorized | 401 — the key is missing or invalid |
| forbidden | 403 — a pk_ key used from a domain outside its allow-list |
| not_found | 404 — no published post with that slug |
| validation_error | 422 — a limit outside 1..100, or an unreadable cursor |
| server_error | 5xx |
| network | the request never completed, so status is 0 |
| unknown | anything else |
Replacing fetch
fetch is the one extension point, and the only one an adapter needs:
createWritbeeClient({
baseUrl,
apiKey,
fetch: (url, init) => fetch(url, { ...init, cache: "force-cache" }),
})@writbee/next uses it to add Next's revalidate and cache tags; a test can
use it to answer without a network.
Notes
- ESM only. A CommonJS caller needs
await import("@writbee/core"). - The client holds no module-level state, so two copies in one
node_modulescannot interfere with each other.
