Kwisple
Browse the docs

Content and data

SDK client

createClient and every method of the typed KwispleClient, plus the Next.js and Astro helpers that wrap it.

@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>;
OptionTypeDefaultMeaning
urlstringrequiredplatform URL (KWISPLE_URL), e.g. https://sites.kwisple.com; trailing slashes are removed
tokenstringrequiredsite API token with the read scope (+ preview for live preview, + schema for pushes and kits)
previewstring | null—a preview token; when set, entry() serves the previewed entry from its draft. Normally you use withPreview() instead
fetchtypeof fetchglobal fetchreplace the fetch implementation (the Astro runtime passes a caching one)
revalidatenumber (seconds)3600how 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
schemaSchemaDef—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 into null (listed per method). code is the API's error code (unauthorized, forbidden, not_found, bad_request, conflict, rate_limited, server_error) or http_error when 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):

TypeMeaning
Entry<typeof schema, "news">a top-level entry of a section: EntrySystem + type + its fields flattened (entry.intro)
EntrySystemid, 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, Seoresolved field values (see Fields reference)
isType(x, "handle")narrows an entry or block union by type
AnyEntryan 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

ExportWhat it doesDetails
getClient(client)the per-request client: client.withPreview(token) when Next draft mode is on, else clientLive preview and revalidation
getPreviewToken(), PREVIEW_COOKIEthe preview token from the site_preview cookie while draft mode is onsame
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 previewsame
LivePreviewclient 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_ORIGINsame
createRevalidateRoute({ secret })POST /api/revalidate: verifies x-kwisple-signature, then revalidateTag with { expire: 0 } for kwisple:all and every tag in the payloadCaching
verifySignature(secret, body, signature)the HMAC check on its ownsame
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 returnsRendering 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 filessame
NotFoundBeacon, createNotFoundRoute(client)log 404s from the browser (path + referrer) so the 404 page and catch-all can stay statically cachedsame
imageProps(asset, { sizes?, priority? })props for next/image: src, alt, width, height, sizes, blur placeholder, focal point as objectPosition, eager + fetchPriority="high" with prioritySEO, performance and security headers
metadataFor(entry, site)Next Metadata from the entry's SEO tabsame
createSitemap(client, { sections, extra? }), createRobots(client, { disallow? })app/sitemap.ts and app/robots.ts factoriessame
JsonLd, organizationJsonLd, webSiteJsonLd, breadcrumbJsonLd, serviceJsonLd, schemaDay, siteOrigin(client)JSON-LD builders and the <script type="application/ld+json"> componentsame
Analyticsthe statistics script tag, from client.privacy() or a privacy you already fetched; disabled while previewingStatistics 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

ExportWhat it doesDetails
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, routesProject 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_COOKIEthe per-request client that reads drafts while the dashboard previewsLive 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 redirectsRendering content
clearKwispleCache(), kwispleEnvempty the memory cache; the resolved environment (url, token, webhookSecret, dashboardOrigin)Caching
imageSources(asset, { sizes?, widths?, priority? })what <Image> needs: width ladder, formats, focal point, placeholderSEO, performance and security headers
Components (@kwisple/sdk/astro/components): Image, Seo, JsonLd, BrandHead, Analytics, Consent, LivePreview, NotFoundBeaconserver-rendered; Consent ships a small module script only when the site needs consent, LivePreview only while previewingthe 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 + noindexStaging

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.

Last updated 2026-10-30