Official Node.js client for Draftbase, the MDX-based headless CMS for React developers. Zero runtime dependencies, uses global fetch, fully typed — use it to fetch published content, manage entries/content types/media, and sync your CMS schema into TypeScript types, from any Node.js backend or framework (Next.js, Astro, Remix, SvelteKit, Nuxt, Express, Cloudflare Workers).
pnpm add @draftbase/sdk
# or: npm install @draftbase/sdk
# or: yarn add @draftbase/sdkimport { createClient } from "@draftbase/sdk";
const draftbase = createClient({ apiKey: process.env.DRAFTBASE_API_KEY! });Options: apiKey (required), baseUrl (default https://api.draftbase.co), environment (default envId applied to delivery/entries reads, overridable per call), retries (read requests only, default 2), cacheTtlMs (cache for read requests, default 0 = disabled), cache ("memory" default or "disk"), diskCacheDir (only for cache: "disk", default an OS-temp folder).
Use a delivery-scoped key for the top-level getEntries/getEntry/graphql methods, and a management-scoped key for everything under entries, contentTypes, media, webhooks.
A preview-scoped key is a drop-in replacement for a delivery key on the same top-level getEntries/getEntry methods — no code changes needed, just a different key. The difference: it serves every non-archived entry, including drafts and unpublished edits, instead of published-only content, and its responses are never cached (Cache-Control: no-store).
Point a staging deploy or local dev server at a preview key so it can render real pages against draft content before publish, then point production at a delivery key so it only ever serves what's actually published:
// lib/draftbase.ts
const isProd = process.env.NODE_ENV === "production";
export const draftbase = createClient({
apiKey: isProd ? process.env.DRAFTBASE_DELIVERY_KEY! : process.env.DRAFTBASE_PREVIEW_KEY!,
});Create both keys from the dashboard (or POST /orgs/api-keys with scope: "preview" / scope: "delivery") against the same environment — this is orthogonal to the environment option, which separates data by env, not by draft/published visibility.
The client itself is framework-agnostic (plain Node.js, global fetch) — only the calling convention changes per framework. Instantiate createClient once in a shared module and import it wherever you need content.
// lib/draftbase.ts
import { createClient } from "@draftbase/sdk";
export const draftbase = createClient({ apiKey: process.env.DRAFTBASE_API_KEY! });// app/blog/[slug]/page.tsx
import { draftbase } from "@/lib/draftbase";
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const entry = await draftbase.getEntry(slug);
if (!entry) return notFound();
return <article>{entry.fields.title}</article>;
}Also works in Route Handlers (app/api/**/route.ts) and Server Actions — anywhere Node.js fetch runs server-side.
---
// src/pages/blog/[slug].astro
import { draftbase } from "../../lib/draftbase";
const entry = await draftbase.getEntry(Astro.params.slug);
---
<h1>{entry.fields.title}</h1>// app/routes/blog.$slug.tsx
import { draftbase } from "~/lib/draftbase";
import { data } from "react-router";
export async function loader({ params }: Route.LoaderArgs) {
const entry = await draftbase.getEntry(params.slug!);
if (!entry) throw data(null, { status: 404 });
return { entry };
}// src/routes/blog/[slug]/+page.server.ts
import { draftbase } from "$lib/draftbase";
import { error } from "@sveltejs/kit";
export async function load({ params }) {
const entry = await draftbase.getEntry(params.slug);
if (!entry) error(404);
return { entry };
}// server/api/blog/[slug].ts
import { draftbase } from "~/server/utils/draftbase";
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, "slug");
const entry = await draftbase.getEntry(slug!);
if (!entry) throw createError({ statusCode: 404 });
return entry;
});import express from "express";
import { draftbase } from "./lib/draftbase.js";
const app = express();
app.get("/blog/:slug", async (req, res) => {
const entry = await draftbase.getEntry(req.params.slug);
if (!entry) return res.sendStatus(404);
res.json(entry);
});All of the above use getEntry/getEntries (delivery-scoped, published-only reads) — swap in entries.*/contentTypes.*/media.* (management-scoped) the same way for authoring/admin UIs.
const { entries, nextCursor } = await draftbase.getEntries({
contentTypeId: "blogPost", // optional
locale: "en-US", // optional
limit: 25, // optional, max 100, default 25
after: nextCursor, // optional, cursor pagination
});
const entry = await draftbase.getEntry("<entry id>"); // null if not foundgetEntries/getEntry responses are CDN-cached at the edge (per API key, keyed on the full query) — a cache hit is served without reaching the origin, so it doesn't count against your org's rate limit. Cache misses do.
To pull a whole catalog (every blog post for a sitemap, every entry of a content type) instead of one page, use getAllEntries — it follows nextCursor for you:
for await (const post of draftbase.getAllEntries<BlogPostFields>({ contentTypeId: "blogPost" })) {
// one entry at a time, across as many pages as it takes
}Not usable with mode: "semantic" (no cursor pagination there — page manually with getEntries instead).
Pin a client to one environment (matches each entry's envId, e.g. "staging" vs "production"):
const draftbase = createClient({ apiKey, environment: "staging" });
await draftbase.getEntries(); // envId=staging
await draftbase.getEntries({ envId: "production" }); // per-call overrideEach locale of a piece of content is its own entry, linked to its translations by groupId. Create the first locale normally, then pass its _id (or any sibling's groupId) as groupId when creating each additional locale:
const { id: enId } = await draftbase.entries.create({
contentTypeId: "blogPost",
locale: "en-US",
fields: { title: "Hello" },
});
await draftbase.entries.create({
contentTypeId: "blogPost",
locale: "fr-FR",
fields: { title: "Bonjour" },
groupId: enId, // links this entry to enId's locale group
});There's no separate endpoint for this — the same getEntry/entries.get you already use take a locales flag that attaches a localizations array (the entry's siblings, excluding itself) to the response:
const entry = await draftbase.getEntry(enId, undefined, undefined, true);
entry.localizations; // -> [{ _id: "...", locale: "fr-FR", ... }]getLocalizations (published-only, delivery-scoped) and entries.getLocalizations (any status, management-scoped) are thin convenience wrappers over that same call, returning the entry and its siblings as one flat array — including itself:
const locales = await draftbase.getLocalizations(enId); // [enEntry, frEntry, ...], published only
const all = await draftbase.entries.getLocalizations(enId); // any statusAn entry with no linked translations yet returns just itself (localizations is absent/[]). locale filters on getEntries/entries.list still work as before for listing one locale at a time — locales/getLocalizations is for pulling every locale of one specific entry, e.g. to build a language switcher.
Same delivery-scoped, published-only data as getEntries/getEntry, queryable as GraphQL (Query.entries, Query.entry, matching args including envId):
const data = await draftbase.graphql<{ entry: { fields: { title: string } } }>(
`query($id: ID!) { entry(id: $id) { fields } }`,
{ id: "<entry id>" },
);Throws GraphqlError (with an errors array) if the response has GraphQL errors.
await draftbase.entries.list({ contentTypeId, locale, status }); // any status, all filters optional
await draftbase.entries.get(id); // null if not found
await draftbase.entries.create({ contentTypeId, locale, fields }); // -> { id }, starts as "draft"
await draftbase.entries.create({ contentTypeId, locale, fields, groupId }); // links as another locale of `groupId`'s entry — see Locales below
await draftbase.entries.update(id, fields); // replaces fields, bumps version, snapshots a revision
await draftbase.entries.updateStatus(id, "published"); // draft | review | published | archived
await draftbase.entries.rollback(id, version); // restore fields from a past revision
await draftbase.entries.delete(id);
await draftbase.entries.schedulePublish(id, "2026-01-01T09:00:00Z"); // ISO 8601, replaces any existing schedule
await draftbase.entries.cancelSchedule(id);await draftbase.contentTypes.list();
await draftbase.contentTypes.get(id);
await draftbase.contentTypes.create({ name, fields }); // -> { id }
await draftbase.contentTypes.update(id, { name, fields });
await draftbase.contentTypes.delete(id); // fails if entries still reference itImages are resized (max 1920x1920 by default, org-configurable), converted to WebP, and served off a CDN — asynchronously, right after upload. confirmUpload returns immediately with status: "pending"; poll media.get until it flips to "ready" (or "failed").
const { url, fields, storageKey } = await draftbase.media.getUploadUrl({
fileName,
contentType,
});
const form = new FormData();
for (const [key, value] of Object.entries(fields)) form.append(key, value);
form.append("file", file); // must be the last field
await fetch(url, { method: "POST", body: form }); // presigned POST — enforces the org's size limit
const { id } = await draftbase.media.confirmUpload({
storageKey,
contentType,
altText,
});
const asset = await draftbase.media.get(id); // { status: "pending" | "ready" | "failed", width, height, url, ... }Per-org defaults (max 1920x1920px, 5MB, WebP conversion on) — override, or read what's active:
await draftbase.orgs.getMediaSettings(); // { enabled, maxWidth, maxHeight, maxUploadBytes }
await draftbase.orgs.updateMediaSettings({
maxWidth: 2560,
maxUploadBytes: 10 * 1024 * 1024,
});
await draftbase.orgs.updateMediaSettings({ enabled: false }); // skip resize/convert, keep originals as-isawait draftbase.webhooks.list();
await draftbase.webhooks.create({
url,
events: ["entry.moved_to_review"],
includeContent: true,
envId: "production",
}); // -> { id, secret }
await draftbase.webhooks.delete(id);Webhook requests include a versioned event envelope and HMAC signatures. Use entry.moved_to_review with includeContent: true to trigger an external Claude skill or Python/JavaScript Review Readiness runner as an example.
interface BlogPostFields {
title: string;
body: string;
}
const { entries } = await draftbase.getEntries<BlogPostFields>({
contentTypeId: "blogPost",
});
entries[0].fields.title; // stringNon-2xx responses (other than a 404, which resolves to null) throw DraftbaseError with status and message.
import { DraftbaseError } from "@draftbase/sdk";
try {
await draftbase.getEntries();
} catch (err) {
if (err instanceof DraftbaseError) console.error(err.status, err.message);
}- Read requests (
getEntries/getEntry/graphql/entries.list/entries.get/contentTypes.list/contentTypes.get) retry automatically on network errors or429/502/503/504, with exponential backoff (300ms,600ms, ...). Disable withretries: 0. - Mutations (
create/update/delete/...) are never auto-retried — they aren't idempotent. - Set
cacheTtlMsoncreateClientto cache read responses for that long (default0, disabled). Create a second client with a differentcacheTtlMsif you need both cached and uncached reads in one process. cache: "memory"(default) caches per client instance/process.cache: "disk"persists across processes underdiskCacheDir(default an OS-temp folder) — Node-only, and only useful where the filesystem is writable and persistent between invocations (a long-running server or local dev, not typical serverless/edge runtimes). On React Native,cache: "disk"automatically falls back to memory (the package'sbrowser/react-nativefields point Metro at the Node-freecacheStore.native.jsbuild) — no bundler config needed.
One draftbase bin covers login, codegen, and migration. Install it globally to get a bare
draftbase command on your PATH:
npm install -g @draftbase/sdk
# or: pnpm add -g @draftbase/sdk / yarn global add @draftbase/sdk
draftbase loginNo install: since the package name (@draftbase/sdk) differs from the bin name (draftbase),
plain npx draftbase ... won't resolve — use npx -p @draftbase/sdk draftbase ... instead:
npx -p @draftbase/sdk draftbase logindraftbase login opens your browser for OAuth login and stores a refreshable session under
~/.draftbase. Every subcommand below accepts either that session or an explicit
--api-key <management-key> (or DRAFTBASE_API_KEY).
Pull your org's content types and generate a .d.ts with one interface per content type:
draftbase types --out src/types/draftbase.d.ts
# or with an API key instead of a login session:
draftbase types --api-key <management-key> --out src/types/draftbase.d.tsRe-run whenever content types change (e.g. a predev/CI step) to keep Entry<BlogPostFields> etc. in sync with the CMS schema.
draftbase migrate moves content models, locales, entries, and media (images/files) from another
CMS into Draftbase. It's resumable — progress is written to a checkpoint file after every item, so
you can stop (Ctrl+C) and rerun the same command to continue where it left off, with nothing
recreated twice.
# Contentful — from a `contentful space export` JSON dump
draftbase migrate --source contentful --file export.json --checkpoint ./migration.json
# WordPress — reads posts/pages straight from the live REST API
draftbase migrate --source wordpress --url https://example.com --checkpoint ./migration.json
# Preview counts without writing anything
draftbase migrate --source contentful --file export.json --checkpoint ./migration.json --dry-runEntry-to-entry and entry-to-asset references are resolved automatically, even across circular references, once every item has been created — including entry links inside Contentful rich text, which is converted to MDX (headings, marks, lists, tables, links, embeds), not carried over as raw document JSON. Known limits: WordPress content stays as raw rendered HTML, not converted to MDX; WordPress multilingual plugins (WPML/Polylang) aren't supported (single-locale only); embedded/linked assets inside converted rich text keep pointing at their original Contentful-hosted URL rather than the migrated copy.
Only Contentful and WordPress ship out of the box, both driven by the draftbase migrate CLI
subcommand above — migration tooling (migrate(), MigrationSource, adapters) is CLI-internal and
not exported from @draftbase/sdk's main entry. To migrate from anywhere else, add a new adapter in
the SDK source (src/migration/adapters/) — any function returning a MigrationSource
(assets()/contentTypes()/entries(), each an async iterable) works with migrate() and the same
checkpoint/resume/retry behavior, no engine changes needed — and wire it into MIGRATION_SOURCES in
src/cli.ts.
If you're an agent implementing Draftbase in a project, follow this checklist:
- Install:
pnpm add @draftbase/sdk(ornpm/yarnequivalent — detect the project's package manager first). - Never hardcode API keys. Read
apiKeyfrom an environment variable (DRAFTBASE_API_KEYor similar) — add it to.env.exampleif the project has one, and confirm it's in.gitignore, don't commit it. - Pick the right key scope:
deliverykey for read-only published content (getEntries/getEntry/graphql);previewkey for the same read-only methods but including drafts/unpublished edits, e.g. a staging deploy previewing content before publish (see Staging/preview before publish);managementkey for anything underentries/contentTypes/media/webhooks. Ask the user which they have if unclear — adelivery/previewkey cannot call management methods and will 401/403. - All methods are async and return typed data directly (no
.datawrapper) —entries.list()etc. — exceptgetEntry/entries.get, which resolve tonullon a 404 instead of throwing. Handle thatnullcase explicitly. - Don't wrap calls in retry loops — reads already retry internally (see Retries & caching); adding your own doubles the backoff.
- Generate types before writing content-shape code: run
draftbase types --api-key <management-key> --out <path>first, then import the generated interfaces as theEntry<T>type param — don't hand-write field interfaces that can drift from the live schema. - This package has zero runtime dependencies and works in any Node/Next.js context (route handlers, server components, scripts) — it is not usable in a browser bundle (no
apiKeyshould ever ship client-side).
What is Draftbase?
Draftbase is a lightweight, MDX-based headless CMS built for React and Next.js developers. Content is authored as MDX/markdown with typed fields, then delivered via REST, GraphQL, or this SDK, and rendered with @draftbase/renderer into React, Vue, or static HTML.
How is @draftbase/sdk different from calling the REST API directly?
It adds typed responses, automatic retries with backoff on transient read failures, optional response caching, cursor pagination handling, and a draftbase types command that generates TypeScript interfaces from your live content types — all of that would otherwise be hand-rolled fetch boilerplate.
Does this work with the Next.js App Router / React Server Components?
Yes — every method returns a plain Promise, so await draftbase.getEntry(id) works directly inside an async Server Component or Route Handler with no extra data-fetching library.
Can I use this SDK in the browser? No — it's a server-side client. API keys are secrets and must never ship to a browser bundle; call this SDK from a server component, route handler, loader, or backend, and expose only the data you need to the client.
How do I keep TypeScript types in sync with my CMS schema?
Run draftbase types --api-key <management-key> --out <path> (see Content type sync) whenever content types change; it regenerates one interface per content type from the live schema.
- npm
- Source
- Issues
@draftbase/renderer— renders the MDX this SDK fetches- draftbase.co — product site
- API reference — full REST API this SDK wraps
- MCP server docs
- Framework support
- Docs
- Pricing