@kwisple/sdk exports createClient, a typed client for the Delivery API. Pass your content model as the type parameter and every entry, global and form is typed from cms.config.ts, without a codegen step.
// src/lib/cms.ts (Next.js)
import { createClient } from "@kwisple/sdk";
import type schema from "../../cms.config";
export const client = createClient<typeof schema>({
url: process.env.KWISPLE_URL!,
token: process.env.KWISPLE_TOKEN!,
});In Astro you do not call createClient yourself: createKwispleClient() from @kwisple/sdk/astro/server reads the environment and adds the in-memory cache (see Framework helpers).
The client runs on the server only. The token must never reach a browser.
createClient(options)
function createClient<S extends SchemaDef = SchemaDef>(options: ClientOptions): KwispleClient<S>;| Option | Type | Default | Meaning |
|---|---|---|---|
url | string | required | platform URL (KWISPLE_URL), e.g. https://sites.kwisple.com; trailing slashes are removed |
token | string | required | site API token with the read scope (+ preview for live preview, + schema for pushes and kits) |
preview | string | null | — | a preview token; when set, entry() serves the previewed entry from its draft. Normally you use withPreview() instead |
fetch | typeof fetch | global fetch | replace the fetch implementation (the Astro runtime passes a caching one) |
revalidate | number (seconds) | 3600 | how long Next's data cache may keep a GET answer (next.revalidate); the publish webhook purges earlier |
schemaEnv | "dev" | "production" | process.env.KWISPLE_SCHEMA | "dev" sends x-kwisple-schema: dev on every call, so reads use the development model kwisple dev pushes |
schema | SchemaDef | — | your cms.config value. The first call then compares its hash with GET /api/v1/schema once and warns when the platform serves another model |
onSchemaMismatch | "warn" | "throw" | "warn" | one console.warn line, or a thrown KwispleError(409, "schema_mismatch") (fails the build) |
Also exported:
SITE_TAG = "kwisple:all": the Next cache tag every GET carries. The revalidate route purges it.schemaHash(schema): the platform's hash of a model (first 16 hex characters of the SHA-256 of its JSON), for your own comparisons.KwispleError:{ status: number; code: string; message: string }, thrown for every non-2xx answer except the ones a method turns intonull(listed per method).codeis the API's error code (unauthorized,forbidden,not_found,bad_request,conflict,rate_limited,server_error) orhttp_errorwhen the body had none.- The types
SiteInfo,StagingInfo,TreeNode,Brand,PrivacyInfo,ConsentConfig,PushResult,SiteSeed,SeedResult,RemoteSchema,AnyEntry,EntriesQuery,FieldFilter,Globals<S>,SiteEntryOf<S>.
How requests are made
Every call goes to ${url}/api/v1${path} with authorization: Bearer <token>. GET requests that are not previews and not marked no-store are made with next: { revalidate, tags: ["kwisple:all", …] }, so in Next.js they land in the data cache and are purged by createRevalidateRoute; outside Next the extra option is ignored by fetch. Preview reads and every POST use cache: "no-store". Some methods add a second tag (kwisple:site, kwisple:brand, kwisple:privacy, kwisple:redirects) so you can purge them selectively from your own code with revalidateTag.
Methods
The client interface, in the order the source lists them. S is your schema type.
withPreview(token)
withPreview(token: string | null): KwispleClient<S>Returns a copy of the client with options.preview set. With a token, entry() sends x-kwisple-preview and reads the previewed entry from the editor's draft, uncached. Use it per request, never for the shared client. In Next.js getClient(client) from @kwisple/sdk/next does this when draft mode is on; in Astro getClient(client, Astro) when the preview cookie is present.
site()
site(): Promise<SiteInfo>GET /api/v1/site. Memoised per client instance: the first call fetches, later calls reuse the promise. Tag kwisple:site. Throws KwispleError(500, "no_site") when the platform answers without a site.
const site = await client.site();
// { id, name, mode, siteUrl, locales, defaultLocale, isLive, staging }entries(query)
entries<H extends SectionHandle<S>>(q: EntriesQuery<H> & { section: H }): Promise<{ items: Entry<S, H>[]; total: number }>;
entries(q?: EntriesQuery): Promise<{ items: AnyEntry[]; total: number }>;GET /api/v1/entries. With a single section the items are typed as that section's entries (a union of its entry types, discriminated by type); otherwise AnyEntry. Arrays are sent comma-separated, where as JSON, parentId: null as null. A 400 from the platform (unknown field, bad operator) throws KwispleError(400, "bad_request"). Every option is documented in Querying entries.
const { items, total } = await client.entries({
section: "news",
orderBy: "postDate desc",
limit: 24,
select: ["intro", "image"],
});entry(query)
entry<H extends SectionHandle<S> = never>(q: { uri?: string; id?: string; locale?: string; section?: H }): Promise<EntryFor<S, H> | null>GET /api/v1/entry. Resolves to null on 404. section is a type hint only (it narrows the return type; it is not sent). Honours withPreview. Without a locale the site's default locale is used.
const about = await client.entry({ uri: "over-ons", section: "pages" });
const home = await client.entry({ uri: "__home__", locale: "fr" });entryByPath(path)
entryByPath(path: string): Promise<{ entry: SiteEntryOf<S> | null; locale: string; uri: string }>The resolver for a catch-all route. Fetches site() for the locales, strips query and hash, treats a first segment that is a non-default site locale as the locale prefix, converts the rest with pathToUri (/ becomes __home__) and calls entry({ uri, locale }). Always returns the locale and uri it derived, so a 404 page knows them too. See Localisation and URIs.
const { entry, locale } = await cms.entryByPath("/fr/a-propos");
if (!entry) return handleNotFound(cms, "/fr/a-propos", { report: false });tree(section, options?)
tree(section: SectionHandle<S>, o?: { locale?: string; maxLevel?: number }): Promise<TreeNode[]>GET /api/v1/tree. Live entries of a structure section as nested { id, section, type, title, slug, uri, url, children } nodes, in the editor's order. maxLevel: 1 gives the top level only. Returns [] when the answer has no items.
globals(locale?)
globals(locale?: string): Promise<Globals<S>>GET /api/v1/globals. Every global set keyed by handle, each typed from its fields: const { footer, contact } = await client.globals();.
brand()
brand(): Promise<Brand | null>GET /api/v1/brand. null when the workspace has no brand kit (404). Tag kwisple:brand. Shape and usage in Brand kit and privacy data.
privacy(locale?)
privacy(locale?: string): Promise<PrivacyInfo>GET /api/v1/consent. The consent configuration (banner texts, categories, services, version) and the statistics settings (script URL, collect URL, whether counting needs consent). Tag kwisple:privacy. Throws KwispleError(500, "no_privacy") on an empty answer. See Statistics and consent and Brand kit and privacy data.
matchRedirect(path)
matchRedirect(path: string): Promise<{ to: string; statusCode: number } | null>GET /api/v1/redirects/match. null when no redirect matches. Cached, misses included, with revalidate: 300 and tag kwisple:redirects: redirects created by a publish (a URI change) arrive with the webhook, a redirect added by hand in Doorverwijzingen (redirects) applies within five minutes. handleNotFound and withRedirects call this for you.
reportNotFound(path, referrer?)
reportNotFound(path: string, referrer?: string): Promise<void>POST /api/v1/not-found. Logs a 404 in the dashboard. Never throws (network errors are swallowed) and does nothing on staging (KWISPLE_ENV=staging), because testers clicking around unfinished pages are not the site's 404s.
reportAgentVisit(path, userAgent)
reportAgentVisit(path: string, userAgent: string): Promise<void>POST /api/v1/agent. Counts a page read by an AI agent under AI-bezoek (AI visits) in the statistics; the platform decides from the user agent whether it is one. Never throws, skipped on staging. You normally call maybeReportAiAgent(client, request) instead, which filters first; see Statistics and consent.
verifyPreview(token)
verifyPreview(token: string): Promise<{ entryId: string; locale: string; url: string } | null>GET /api/v1/preview/verify (needs the preview scope). null on 401 (invalid or expired token) and 404 (entry gone); other errors throw. createPreviewRoute uses it.
submitForm(input)
submitForm<H extends FormHandle<S>>(input: {
form: H;
values: FormValues<S, H & string>;
entryId?: string;
website?: string;
startedAt?: number;
slot?: string;
confirm?: { to: string; subject: string; text: string };
clientIp?: string;
}): Promise<{ ok: true; id?: string }>POST /api/v1/forms. Server-side only. form and values are typed when the form is declared with defineForm in your schema (required fields required, checkboxes as booleans); for an undeclared handle values is Record<string, string | boolean>. clientIp is not sent in the body but as the x-kwisple-client-ip header, so the platform rate-limits per visitor rather than per site. Throws KwispleError(409, "conflict") when slot is taken and KwispleError(400, "bad_request") when a typed form does not validate. A spam-dropped submission still resolves { ok: true } (without id). Everything about forms: Forms, bookings and confirmations.
formSlots(form, query?)
formSlots(form: string, q?: { from?: string; to?: string }): Promise<string[]>GET /api/v1/forms/{form}/slots, never cached. The slot keys already claimed by non-spam submissions, sorted, at most 2000. from/to are string bounds on the key ({ from: "2026-11", to: "2026-11" } covers November when keys look like 2026-11-03T10:00).
stagingInfo()
stagingInfo(): Promise<StagingInfo | null>GET /api/v1/site without caching, returning site.staging: { url, protected, version } for a staging site, null for a production site. The staging gate calls it (memoised 30 s).
stagingLogin(password, options?)
stagingLogin(password: string, o?: { clientIp?: string }): Promise<string | null>POST /api/v1/staging/login. Resolves to the password's version string when the password is right, null when it is wrong (401) or the site has no staging password (404). clientIp goes in x-kwisple-client-ip. Used by stagingGate; see Staging.
remoteSchema()
remoteSchema(): Promise<RemoteSchema>GET /api/v1/schema, never cached: { version, source: "code" | "standard" | "none", hash, schema }. With schemaEnv: "dev" the development model. Throws KwispleError(500, "no_schema") on an empty answer.
exportSeed()
exportSeed(): Promise<SiteSeed>GET /api/v1/seed (scope schema), never cached. The site's live content as a kit, without files and cross-entry links. What kwisple export writes. See Site kits: seed and export.
seed(seed, options?)
seed(seed: SiteSeed, o?: { force?: boolean }): Promise<SeedResult>POST /api/v1/seed (scope schema). Writes a kit into the site; the platform refuses with KwispleError(409, "conflict") when the site already has content, unless force: true. Result { created, globals, problems }.
pushSchema(schema, options?)
pushSchema(schema: SchemaDef, o?: { force?: boolean; dryRun?: boolean; env?: "dev" | "production" }): Promise<PushResult>POST /api/v1/schema (scope schema). The schema is serialised through JSON first (functions and with() helpers disappear). A 422 answer (problems) does not throw: read result.problems. env: "dev" stores the development model, which only reads with x-kwisple-schema: dev see. What kwisple push and kwisple dev do; see Workflow: dev, push and changes.
Typing helpers
From @kwisple/sdk (re-exported from @kwisple/cms):
| Type | Meaning |
|---|---|
Entry<typeof schema, "news"> | a top-level entry of a section: EntrySystem + type + its fields flattened (entry.intro) |
EntrySystem | id, section, title, slug, uri, url, locale, postDate, expiryDate, parentId, level, seo, updatedAt |
BlockOf<typeof blockType> | a matrix block: { id, type, title } + fields |
Global<S, "footer">, GlobalHandle<S>, SectionHandle<S> | globals and handles of a schema |
FormHandle<S>, FormValues<S, H> | forms declared with defineForm |
Asset, EntryRef, Link, Seo | resolved field values (see Fields reference) |
isType(x, "handle") | narrows an entry or block union by type |
AnyEntry | an entry when the section is not known: EntrySystem & { type: string } & Record<string, unknown> |
Framework helpers
The client is framework-free. Two sub-packages wire it into a site; their details live on the site pages, this is the map.
@kwisple/sdk/next
| Export | What it does | Details |
|---|---|---|
getClient(client) | the per-request client: client.withPreview(token) when Next draft mode is on, else client | Live preview and revalidation |
getPreviewToken(), PREVIEW_COOKIE | the preview token from the site_preview cookie while draft mode is on | same |
createPreviewRoute(client) | GET /api/preview?token=…: verifies with client.verifyPreview, enables draft mode, sets the cookie (SameSite=None; Secure in production, Lax in development, 1 h) and redirects to the entry; ?exit=1&redirect=/path leaves preview | same |
LivePreview | client component for the root layout while previewing: listens for the dashboard's kwisple:preview:refresh message, calls router.refresh(), shows the Voorbeeldweergave badge; allowedOrigin = KWISPLE_DASHBOARD_ORIGIN | same |
createRevalidateRoute({ secret }) | POST /api/revalidate: verifies x-kwisple-signature, then revalidateTag with { expire: 0 } for kwisple:all and every tag in the payload | Caching |
verifySignature(secret, body, signature) | the HMAC check on its own | same |
handleNotFound(client, path, { referrer?, report? }) | follows a dashboard redirect (permanentRedirect for 301/308, redirect otherwise), else reports the 404 (unless report: false) and calls notFound(). Never returns | Rendering content |
withRedirects(client, req) | proxy.ts helper: checks redirects before rendering (one API call per page request); answers 410 for gone pages. Skips /api, /_next and files | same |
NotFoundBeacon, createNotFoundRoute(client) | log 404s from the browser (path + referrer) so the 404 page and catch-all can stay statically cached | same |
imageProps(asset, { sizes?, priority? }) | props for next/image: src, alt, width, height, sizes, blur placeholder, focal point as objectPosition, eager + fetchPriority="high" with priority | SEO, performance and security headers |
metadataFor(entry, site) | Next Metadata from the entry's SEO tab | same |
createSitemap(client, { sections, extra? }), createRobots(client, { disallow? }) | app/sitemap.ts and app/robots.ts factories | same |
JsonLd, organizationJsonLd, webSiteJsonLd, breadcrumbJsonLd, serviceJsonLd, schemaDay, siteOrigin(client) | JSON-LD builders and the <script type="application/ld+json"> component | same |
Analytics | the statistics script tag, from client.privacy() or a privacy you already fetched; disabled while previewing | Statistics and consent |
The consent UI for React lives in @kwisple/sdk/consent (ConsentProvider, ConsentBanner, useConsent, ConsentGate, ConsentEmbed, ConsentScript, CookieSettingsButton, and the framework-free store functions) with @kwisple/sdk/consent.css for the default look.
@kwisple/sdk/astro
| Export | What it does | Details |
|---|---|---|
kwisple(options) (default export of @kwisple/sdk/astro) | the integration: declares the KWISPLE_* variables in astro:env (all secret), allows platform images for astro:assets, compiles the SDK, injects /api/preview, /api/revalidate, /api/not-found and a middleware when the site renders on demand, /sitemap.xml and /robots.txt. Options sitemap, robots, cacheTtl, routes | Project structure (Astro) |
createKwispleClient<typeof schema>({ schema? }) (@kwisple/sdk/astro/server) | the client, reading KWISPLE_URL/KWISPLE_TOKEN lazily and wrapping fetch in the in-memory cache (cachedFetch: GETs without a preview header, 200 and 404 answers, cacheTtl default 3600 s, 2000 entries, off in astro dev) | Caching |
getClient(client, Astro), previewToken(Astro), PREVIEW_COOKIE | the per-request client that reads drafts while the dashboard previews | Live preview and revalidation |
resolveEntry(Astro, client) | the catch-all resolver: { entry, locale, uri, api, response, notFound }; response is a redirect or 410 from the dashboard's redirects | Rendering content |
clearKwispleCache(), kwispleEnv | empty the memory cache; the resolved environment (url, token, webhookSecret, dashboardOrigin) | Caching |
imageSources(asset, { sizes?, widths?, priority? }) | what <Image> needs: width ladder, formats, focal point, placeholder | SEO, performance and security headers |
Components (@kwisple/sdk/astro/components): Image, Seo, JsonLd, BrandHead, Analytics, Consent, LivePreview, NotFoundBeacon | server-rendered; Consent ships a small module script only when the site needs consent, LivePreview only while previewing | the pages above |
| The middleware (added by the integration) | runs stagingGate when KWISPLE_ENV=staging, reports AI agents with maybeReportAiAgent, marks previewed responses private, no-store + noindex | Staging |
Framework-free helpers in @kwisple/sdk
maybeReportAiAgent(client, request, waitUntil?) and looksLikeAiAgent(request) (Statistics and consent); stagingGate, isStaging, STAGING_LOGIN_PATH (Staging); blockAttrs(type, index) to mark page-builder blocks for the statistics; resolveSeo, collectSitemap, robotsRules, sitemapXml, robotsTxt and the JSON-LD builders; appSessionFrom, appPlatform, appLevelAtLeast for apps; verifySignature and webhookTags from the webhook module through the Next/Astro routes.