Two flows connect the dashboard to your site. Live preview shows an editor's unsaved draft inside the entry editor, rendered by your own code. Revalidation tells your site that published content changed, so cached pages are dropped and the next visitor gets the new version. Both are a route in your site plus a setting in the dashboard, and both are already wired up in the starters.
| Live preview | Revalidation | |
|---|---|---|
| Dashboard setting | Preview-URL | Webhook bij publiceren (webhook on publish) + Webhook-geheim (webhook secret) |
| Where | Website → Instellingen → Ontwikkeling (development), card Site | Same card |
| Site route | GET /api/preview?token=… | POST /api/revalidate |
| Next.js | createPreviewRoute(client) + <LivePreview /> | createRevalidateRoute({ secret }) |
| Astro | injected by kwisple() + <LivePreview /> | injected by kwisple() |
| Token scope | preview | none (HMAC signature with the webhook secret) |
kwisple link fills in the Preview-URL with http://localhost:<dev port>/api/preview when it is still empty and generates the webhook secret. For production you set both to your live address (Quick start).
Live preview
The flow
- An editor opens the preview in the entry editor. The dashboard creates a signed preview token (valid for one hour, bound to the site, the entry, the locale and the person) and opens your Preview-URL with
?token=…in an iframe. Without a Preview-URL the editor sees "Stel eerst een preview-URL in bij Instellingen › Ontwikkeling." - Your
/api/previewroute callsclient.verifyPreview(token), which asksGET /api/v1/preview/verify(the site token needs thepreviewscope). The platform answers with the entry id, the locale and where to send the editor: the entry's public path, or/__preview?entry=<id>&locale=<locale>for an entry that was never published and has no URI yet. - The route stores the token in an HTTP-only cookie
site_preview(one hour;SameSite=None; Securein production because the dashboard frames the site cross-site,Laxlocally) and answers 307 to that path. In Next.js it also turns on draft mode. - The page renders with the per-request client (
getClient), which switches toclient.withPreview(token). Single-entry reads (entry(), andentryByPath()which uses it) then sendx-kwisple-previewand return the draft; they are never cached. Listings, trees and globals keep returning published content. <LivePreview />in the layout posts{ type: "kwisple:preview:ready" }to the dashboard. After every autosave the dashboard posts{ type: "kwisple:preview:refresh" }back, and the page re-renders in place with the scroll position kept. A site that never says it is ready gets a full iframe reload instead.
The route answers 400 "Preview-token ontbreekt." without a token and 401 "Preview-token ongeldig of verlopen. Open de preview opnieuw vanuit het dashboard." when the platform rejects it. ?exit=1 leaves preview mode (and deletes the cookie); add &redirect=/some/path to land somewhere other than /. Only paths on your own site are accepted there.
Next.js
// src/app/api/preview/route.ts
import { createPreviewRoute } from "@kwisple/sdk/next";
import { client } from "@/lib/cms";
export const GET = createPreviewRoute(client);// src/app/layout.tsx (excerpt)
import { LivePreview } from "@kwisple/sdk/next";
import { draftMode } from "next/headers";
const draft = await draftMode();
// …inside <body>:
{draft.isEnabled ? <LivePreview allowedOrigin={process.env.KWISPLE_DASHBOARD_ORIGIN} /> : null}<LivePreview> is a client component: it calls router.refresh() on a refresh message, which re-runs your server components with the draft, and shows a small "Voorbeeldweergave" badge in the corner. Messages from any origin other than allowedOrigin are ignored. getPreviewToken() returns the token in draft mode (else null); PREVIEW_COOKIE is the cookie name.
Draft mode bypasses Next's full-route cache, so previewed pages render per request while every other visitor keeps the cached page. Read draftMode() in the layout, never headers() or cookies().
Astro
The kwisple() integration injects /api/preview when the site renders on demand (output: "server" or an adapter). Render the component while previewing:
---
import { LivePreview } from "@kwisple/sdk/astro/components";
import { kwispleEnv, previewToken } from "@kwisple/sdk/astro/server";
const previewing = Boolean(previewToken(Astro));
---
{previewing ? <LivePreview allowedOrigin={kwispleEnv.dashboardOrigin} /> : null}On a refresh message the Astro component fetches the current page again and swaps the <body> in place (falling back to a full reload on an error). The integration's middleware sets Cache-Control: private, no-store and X-Robots-Tag: noindex on every response while the preview cookie is present, so drafts never reach a CDN or a search engine.
Entries without a URL: /__preview
A never-published entry has no URI, so the platform sends the editor to /__preview?entry=<id>&locale=<locale>. The Next.js starter answers it in src/app/%5F%5Fpreview/page.tsx (the folder name is URL-encoded because Next treats folders starting with _ as private): it reads api.entry({ id, locale }) with the per-request client, calls notFound() outside draft mode, and is marked robots: { index: false, follow: false }. robots.txt disallows /__preview.
The Astro starter has no /__preview page: such entries reach the catch-all and render the 404 until they have been published once.
Previewing a branch
When the repository is connected under Hosting & deploys, the preview has a Voorbeeld op (preview on) menu for the agency team. It lists ready preview deployments, the newest per branch. Choosing one opens the same preview path on that deployment (<deployment URL><path of your Preview-URL>?token=…, default path /api/preview). The preview deployment needs the KWISPLE_* variables of the site, including a token with the preview scope. See Hosting and deploys.
Framing
The dashboard shows your site in an iframe. Neither starter sends X-Frame-Options or a frame-ancestors policy, so framing works out of the box. If you add security headers, allow the dashboard: Content-Security-Policy: frame-ancestors 'self' <KWISPLE_DASHBOARD_ORIGIN> (and the agency's own dashboard host when it works white-label). Never send X-Frame-Options: DENY. Details in SEO, performance and security headers.
Revalidation
The webhook
After every content change the platform first drops its own caches for the site, then sends one POST to the Webhook bij publiceren. The help text in the dashboard gives the shape: https://acme.be/api/revalidate. The Webhook-geheim is created as soon as a webhook URL is saved; Vernieuwen (renew) makes a new one, which you then also set as KWISPLE_WEBHOOK_SECRET in the site.
POST /api/revalidate HTTP/1.1
content-type: application/json
x-kwisple-signature: 5f2c…e91a
{"tags":["cms:entry:…","cms:site:…"],"event":"cms.published","siteId":"…","at":"2026-10-30T09:12:44.120Z"}| Field | Meaning |
|---|---|
tags | Cache tags that changed (at most 200 are read). |
event | What happened (table below). |
siteId | The site. |
at | When the platform sent it (ISO 8601). |
The signature is the HMAC-SHA256, in hex, of the raw body with the webhook secret. verifySignature(secret, body, signature) (exported from @kwisple/sdk/next) compares it in constant time; verify the body exactly as received, before parsing it.
| Event | Sent when |
|---|---|
cms.published | An entry is published. |
cms.moved | An entry moves in a structure. |
cms.trashed, cms.restored | An entry goes to the trash or comes back. |
cms.scheduled | A page's "online vanaf" or "offline vanaf" moment arrives. |
cms.global.saved | A global set is saved. |
cms.redirect.saved, cms.redirect.deleted | A redirect changes. |
cms.schema.pushed | A content model is pushed. |
brand.updated | The brand kit changes (tag kwisple:brand). |
privacy.updated | Cookie and privacy settings change. |
cache.purge | Someone clicked Cache legen (below). |
The platform waits five seconds for an answer, never follows redirects (a 3xx is reported as "Webhook-URL stuurt door; gebruik de eindbestemming.") and in production only calls https addresses on public hosts. A failed webhook never blocks publishing: the editor sees why the site did not answer.
Tags
| Tag | Where it comes from |
|---|---|
kwisple:all | On every SDK read. Both SDK routes always purge it. |
kwisple:site, kwisple:brand, kwisple:privacy, kwisple:redirects | Added by the SDK to site(), brand(), privacy() and matchRedirect(). |
cms:site:<siteId> | In every payload. |
cms:entry:<entryId>, cms:section:<siteId>:<handle>, cms:globals:<siteId>, cms:redirects:<siteId>, cms:schema:<siteId> | Payload tags for what changed. |
Because kwisple:all is always purged, you only need the cms:* tags for your own unstable_cache or fetch calls that do not go through the SDK.
Next.js
// src/app/api/revalidate/route.ts
import { createRevalidateRoute } from "@kwisple/sdk/next";
export const POST = createRevalidateRoute({ secret: process.env.KWISPLE_WEBHOOK_SECRET });The route verifies the signature (401 unauthorized, "Ongeldige handtekening."), parses the tags (400 bad_request, "Body is geen JSON."), calls revalidateTag(tag, { expire: 0 }) for kwisple:all and every payload tag, and answers { "revalidated": true, "tags": [...] }. Expiring hard matters: the next request refetches from the platform at once instead of being served the old page once more.
Astro
The injected /api/revalidate verifies the signature the same way and empties the whole in-memory content cache (clearKwispleCache(), also exported from @kwisple/sdk/astro/server). It answers { "revalidated": true, "cleared": <entries dropped>, "tags": [...] } with Cache-Control: no-store. The cache lives per server process; with several processes each needs its own webhook call, or a shorter cacheTtl.
Time-based fallback
Without a webhook, cached answers still expire: the Next data cache after revalidate seconds (default 3600), the Astro memory cache after cacheTtl seconds (default 3600), redirect lookups after 300 seconds. Keep the webhook; the timers are a safety net.
Testing the route locally
body='{"tags":[],"event":"test"}'
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$KWISPLE_WEBHOOK_SECRET" | sed 's/^.* //')
curl -i -X POST http://localhost:4102/api/revalidate \
-H "content-type: application/json" -H "x-kwisple-signature: $sig" --data "$body"Use port 4104 for the Astro starter. A wrong secret gives the 401.
"Cache legen"
Website → Instellingen → Algemeen → Cache legen (empty cache) is for the agency team. After the confirmation Zeker? Cache legen it empties every cache the platform holds for the site and then sends the webhook with event: "cache.purge" and kwisple:all plus the site's cms:* tags, so your site empties its own cache too. The result line Cache geleegd: shows platform and website, each with a tick or a cross; when your route did not answer with a 2xx it reads website (webhook mislukt) followed by the reason (for example "Site antwoordde met 401." or "Geen webhook ingesteld."). Use it after an infrastructure change, such as a new storage address that cached pages still point at. Publishing never needs it.
Static builds versus server-rendered sites
Revalidation needs a running server that can drop a cache. A fully static build has none, so content changes need a new build.
| Site | On publish | How to set it up |
|---|---|---|
Next.js with ISR (the starter), Astro with output: "server" (the starter) | The webhook purges the cache; the next request renders fresh content. No rebuild. | Webhook bij publiceren → https://<site>/api/revalidate. With a connected repository: Als inhoud verandert → Alleen verversen (only refresh). |
Static build (Astro output: "static", a static export) | A new build of the site. | Connect the repository and choose Opnieuw bouwen (rebuild): every publish queues one build, at most one per minute per site. Without a connected repository, point Webhook bij publiceren at your host's deploy hook. |
A static Astro build has no /api/preview or /api/revalidate (the integration logs this at build time), so live preview needs an on-demand deployment, for example a preview deployment. How the rebuild is scheduled is in Hosting and deploys; the caches on the platform side in Caching.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| The preview stays empty or shows your login | The Preview-URL does not point at a running /api/preview, or the site's token lacks the preview scope. |
| 401 "Preview-token ongeldig of verlopen" | The token is older than an hour or belongs to another site: open the preview again from the dashboard. |
| The preview does not update while typing | <LivePreview> is not rendered (Next: only while draftMode() is on), or allowedOrigin differs from the dashboard's origin. |
| The iframe is blank in production | A frame-ancestors or X-Frame-Options header blocks the dashboard. |
| Published changes do not show | Check the publish message in the editor: a 401 means KWISPLE_WEBHOOK_SECRET differs from Webhook-geheim; a 404 means the route is missing at that address. |
| "Webhook-URL stuurt door" | The URL redirects (http to https, bare domain to www): enter the final address. |