The Astro starter (starters/astro, copied by npm create kwisple-site, the recommended choice) is an Astro 7 site with on-demand rendering through @astrojs/node, Tailwind v4 and @kwisple/sdk/astro. It ships zero JavaScript by default: the only scripts are the cookie banner when the site needs consent, the platform's statistics script, and the live preview while previewing. It runs on port 4104 and scores Lighthouse 98–100 / 100 / 100 / 100 on a production build.
acme-site/
├── astro.config.mjs output: "server", node adapter, the kwisple() integration
├── cms.config.ts, cms/ the content model (identical to the Next starter)
├── src/
│ ├── lib/cms.ts the typed client (createKwispleClient) and the per-request client
│ ├── layouts/Base.astro the layout: <Seo>, <BrandHead>, header/footer, JSON-LD, analytics, consent, preview
│ ├── pages/
│ │ ├── [...path].astro the catch-all resolver (resolveEntry)
│ │ ├── 404.astro the 404 page
│ │ ├── contact.astro a code route with a form that works without JavaScript
│ │ ├── nieuws/index.astro a code route: the news listing
│ │ └── favicon.svg.ts favicon generated from the brand kit
│ ├── views/ one component per entry type, chosen by EntryView.astro
│ ├── builder/ Builder.astro + one component per block
│ ├── components/ CmsLink, RichText, NotFound, Header, Footer
│ └── styles/global.css Tailwind + brand tokens
├── .astro/ generated types (do not edit; gitignored)
├── .github/workflows/ content model in CI (same as Next)
├── .env.example, .gitignore, tsconfig.json, package.jsonThe content model files (cms.config.ts, cms/fields.ts, cms/blocks.ts) are identical to the Next starter's; see Project structure (Next.js) for them. The CLI and the workflow are the same too.
astro.config.mjs
// @ts-check
import node from "@astrojs/node";
import kwisple from "@kwisple/sdk/astro";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "astro/config";
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
server: { port: 4104 },
integrations: [
kwisple({
sitemap: { sections: ["home", "pages", "news"], extra: ["/nieuws", "/contact"] },
}),
],
vite: { plugins: [tailwindcss()] },
});output: "server" with the Node adapter gives on-demand rendering: that is what makes live preview and the publish webhook possible. Content answers are cached in memory and cleared by the webhook on every publish. Tailwind v4 comes in as a Vite plugin.
The kwisple() integration
@kwisple/sdk/astro is plain JavaScript on purpose (Astro loads the config with Node, which does not run TypeScript from node_modules). On astro:config:setup it:
- declares an
astro:envschema forKWISPLE_URL,KWISPLE_TOKEN,KWISPLE_WEBHOOK_SECRET,KWISPLE_DASHBOARD_ORIGINandKWISPLE_ASSET_ORIGIN: allcontext: "server",access: "secret", optional, so none is ever inlined in a client bundle; - reads
.env,.env.<mode>,.env.localand.env.<mode>.localfor config time, and forastro previewcopies them intoprocess.envwhen the host did not set them; - adds the asset origin to
image.remotePatterns, soastro:assetsmay optimise platform images; - sets
vite.ssr.noExternalfor@kwisple/sdkand@kwisple/cms(they ship.tsand.astrofiles that Vite must compile) and registers a virtual modulevirtual:kwisple-astro/optionscarrying your options; - with on-demand rendering (
output: "server"or an adapter): injects the routes/api/preview,/api/revalidateand/api/not-found(not prerendered) and a middleware withorder: "pre"; - injects
/sitemap.xml(whensitemap.sectionsis set) and/robots.txt(unlessrobots: false), prerendered in static builds and rendered per request otherwise; - warns in the terminal when
KWISPLE_URL/KWISPLE_TOKENare missing, and logs that a static build has no preview or webhook route.
On astro:config:done it injects a kwisple.d.ts so the virtual options module is typed.
Options (KwispleAstroOptions):
| Option | Type | Default | Meaning |
|---|---|---|---|
sitemap | { sections: string[]; extra?: string[] } | false | no route | Sections whose entries go into /sitemap.xml, plus code routes such as /nieuws. false or no sections: no sitemap route. |
robots | { disallow?: string[] } | false | {} | Extra disallowed paths for /robots.txt. Everything is disallowed while the site is not live or on staging. false: no route. |
cacheTtl | number | 3600 | Seconds the server keeps delivery API answers in memory. The webhook purges earlier. Off in astro dev. |
stagingOpen | string[] | none | Staging deployment only: path prefixes that check access themselves and stay outside the staging password, e.g. ["/beheer", "/api/beheer"] for apps. |
routes | { preview?: string; revalidate?: string; notFound?: string } | /api/preview, /api/revalidate, /api/not-found | Paths of the injected endpoints. If you change preview, set the same path in the site's Preview-URL; if you change revalidate, in Webhook bij publiceren. |
The named export kwispleIntegration is the same function.
The middleware
Injected by the integration for on-demand sites. Per request it: runs the staging gate when KWISPLE_ENV=staging (password page, dashboard ticket, noindex headers; see Staging); reports AI-agent visits (maybeReportAiAgent); and, when the preview cookie is present, sets Cache-Control: private, no-store and X-Robots-Tag: noindex on the response so previewed drafts never reach a CDN or an index. In astro dev, a site that is not linked yet gets a setup page ("Je site is nog niet gekoppeld") instead of a bare error.
Static builds
With output: "static" and no adapter, pages are rendered at build time, sitemap and robots are prerendered, and there is no preview or webhook route. Set the site's Webhook bij publiceren to your host's deploy hook so every publish rebuilds, and use an on-demand preview deployment for live preview. See Hosting and deploys.
src/lib/cms.ts
import type { BlockOf, Entry } from "@kwisple/sdk";
import { createKwispleClient, getClient } from "@kwisple/sdk/astro/server";
import type { AstroGlobal } from "astro";
import schema from "../../cms.config";
export const client = createKwispleClient<typeof schema>({ schema });
export const cms = (astro: Pick<AstroGlobal, "cookies">) => getClient(client, astro);
export type Schema = typeof schema;
export type SiteEntry = Entry<Schema>;
export type HomeEntry = Entry<Schema, "home">;
export type PageEntry = Entry<Schema, "pages">;
export type NewsEntry = Entry<Schema, "news">;
export type BuilderBlock = PageEntry["builder"][number];
export type BlockOfType<T extends BuilderBlock["type"]> = Extract<BuilderBlock, { type: T }>;
export const dateFormat = (style: "medium" | "long") =>
new Intl.DateTimeFormat("nl-BE", { dateStyle: style, timeZone: "Europe/Brussels" });createKwispleClient differs from Next's createClient in three ways: it reads KWISPLE_URL and KWISPLE_TOKEN itself through astro:env (you may pass url/token to override), it reads them lazily on first use so importing the client never throws during astro check, and it wraps fetch in the in-memory cache (cacheTtl, disabled in dev). Passing schema turns on the model check: one warning when the platform serves another model than your code. cms(Astro) is the per-request client; it reads the previewed draft when the preview cookie is present.
src/layouts/Base.astro
The one layout. Props: either entry (an entry, for <Seo>) or title, description, canonicalPath and noindex for code routes. It fetches client.site(), client.brand() and client.privacy() in parallel and renders:
<Seo entry site={{ name, siteUrl, titleTemplate, defaultDescription }} />from@kwisple/sdk/astro/components, or plain<title>, description, canonical and robots tags for code routes;<BrandHead brand />: font preloads or the Google Fonts stylesheet, and the--brand-*CSS;<Header>and<Footer>around<slot />;<JsonLd>withorganizationJsonLdandwebSiteJsonLd(from@kwisple/sdk);<Analytics privacy disabled={previewing} />and<Consent config={privacy?.consent} />;<LivePreview allowedOrigin={kwispleEnv.dashboardOrigin} />only while previewing (previewToken(Astro)is set).
Until the site has its own address, canonicals and JSON-LD use Astro.url.origin.
src/pages/
[...path].astro: the resolver
---
const r = await resolveEntry(Astro, client);
if (r.response) return r.response;
if (r.notFound) Astro.response.status = 404;
---resolveEntry (from @kwisple/sdk/astro/server) takes the request path, resolves it to an entry with the per-request client, and when nothing matches checks the dashboard's redirects: a redirect gives response (a 301/302/307/308 redirect or a 410 "gone"), otherwise notFound is true and the page renders <NotFound /> inside the layout with status 404. With an entry it renders <EntryView entry> plus a breadcrumbJsonLd. Unlike Next, Astro has no [[...path]]: the homepage is matched by [...path] with an empty path.
404.astro
Astro's 404 route for paths no page matches (for example files). Renders <NotFound /> in the layout with noindex.
contact.astro
A code route with a form that works without JavaScript: on POST it reads Astro.request.formData(), validates, and calls client.submitForm({ form: "contact", values, website, startedAt }) server-side (the token never reaches the browser). website is a honeypot field and startedAt a timestamp for spam timing. It shows the contact global next to the form. Because the form posts to itself, the page must be server-rendered. See Forms, bookings and confirmations.
nieuws/index.astro
The news listing: cms(Astro).entries({ section: "news", orderBy: "postDate desc", limit: 24 }) rendered as cards with <Image>, with priority on the first card's image.
favicon.svg.ts
An endpoint returning an SVG favicon from the brand kit's primary colour and the site name's first letter, cached a day. Replace with public/favicon.svg once the client has a real icon.
src/views/ and src/builder/
EntryView.astro renders <HomeView>, <PageView> or <NewsView> by entry.type (Astro has no switch in templates, so it is three conditional expressions). Each view declares interface Props { entry: HomeEntry } and gets a fully typed entry.
Builder.astro maps block types to components with an object checked by satisfies Record<BuilderBlock["type"], unknown>:
---
const components = {
blockText: Text,
blockMedia: Media,
blockSplit: Split,
blockCards: Cards,
blockCta: Cta,
blockFaq: Faq,
} satisfies Record<BuilderBlock["type"], unknown>;
---A block type without an entry is a type error in astro check. Each block wrapper carries blockAttrs(name, i) for the statistics. blocks/*.astro take block: BlockOfType<"blockMedia"> and so on.
src/components/
| File | What |
|---|---|
CmsLink.astro | A Link field value as the right <a>: target="_blank" and rel="noopener" for newWindow and asset links, rel="noopener" for external URLs, a visually hidden "(opent in een nieuw venster)". Uses the slot or link.text. |
RichText.astro | Rich text from the editor with set:html in a .prose-cms wrapper. |
NotFound.astro | The 404 content with <NotFoundBeacon /> from the SDK components. |
Header.astro | cms(Astro).tree("pages", { maxLevel: 2 }) for the navigation, aria-current="page" from Astro.url.pathname, the logo as <Image widths={[80]}>, fixed items for Nieuws and Contact. |
Footer.astro | Globals footer and contact; shows a cookie-settings link when consent is required. |
Images use <Image> from @kwisple/sdk/astro/components directly: a <Picture> with AVIF + WebP, a width ladder that never upscales, the focal point as object-position, the blur placeholder as a background and priority for the LCP image. There is no CmsImage wrapper as in Next. See Rendering content.
src/styles/global.css
Same idea as the Next starter's globals.css: @import "tailwindcss" and an @theme inline block mapping --brand-* onto --color-*, --font-heading, --font-body and --radius-brand, heading rules from --brand-heading-*, .prose-cms, a .btn class and a reduced-motion block. Imported once in Base.astro.
Config and scripts
tsconfig.jsonextendsastro/tsconfigs/strict, addsnoUncheckedIndexedAccessand the alias@/*→src/*, and includes.astro/types.d.ts.package.jsonscripts:dev(astro dev --port 4104),build,preview(astro preview --port 4104, loads.envthrough the integration),start(node --env-file-if-exists=.env ./dist/server/entry.mjs, the production Node server),typecheck(astro check) andkwisple..gitignore:node_modules,dist,.astro,.env,.env.local..env.example: the five variables with local defaults;kwisple linkfills.env. In production set them in the host's environment (Environment variables and tokens).
How Astro differs from Next, in short
| Topic | Next starter | Astro starter |
|---|---|---|
| Client | createClient({ url, token }) from @kwisple/sdk; getClient(client) from @kwisple/sdk/next | createKwispleClient({ schema }) and getClient(client, Astro) from @kwisple/sdk/astro/server; env read lazily through astro:env |
| Routes for preview, revalidate, not-found | Files in src/app/api/* calling SDK factories | Injected by the integration; paths configurable with routes |
| Cache | Next data cache, tag kwisple:all, revalidate 3600 s | In-memory per process, cacheTtl 3600 s, off in dev |
| Resolver | [[...path]]/page.tsx + handleNotFound | [...path].astro + resolveEntry |
| SEO head | metadataFor() → Next Metadata | <Seo> component (same resolveSeo rules) |
| Images | imageProps() + next/image | <Image> component on astro:assets <Picture> |
| Sitemap / robots | app/sitemap.ts, app/robots.ts with createSitemap/createRobots | Integration options sitemap and robots |
| Live preview badge and refresh | <LivePreview> client component with router.refresh() | <LivePreview> with an inline script that refetches the page and swaps <body> |
| Staging gate | stagingGate in proxy.ts | The integration's middleware |
| AI-agent reporting | maybeReportAiAgent in proxy.ts | The integration's middleware |
| Production | next start, or a standalone Docker image | node ./dist/server/entry.mjs (Node adapter), or a static build on a host with a deploy hook |