Kwisple
Browse the docs

Your site repository › Structure and configuration

Project structure (Astro)

Every folder and file of the Astro starter explained, the @kwisple/sdk/astro integration and its options, and where Astro differs from the Next.js starter.

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.json

The 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:

  1. declares an astro:env schema for KWISPLE_URL, KWISPLE_TOKEN, KWISPLE_WEBHOOK_SECRET, KWISPLE_DASHBOARD_ORIGIN and KWISPLE_ASSET_ORIGIN: all context: "server", access: "secret", optional, so none is ever inlined in a client bundle;
  2. reads .env, .env.<mode>, .env.local and .env.<mode>.local for config time, and for astro preview copies them into process.env when the host did not set them;
  3. adds the asset origin to image.remotePatterns, so astro:assets may optimise platform images;
  4. sets vite.ssr.noExternal for @kwisple/sdk and @kwisple/cms (they ship .ts and .astro files that Vite must compile) and registers a virtual module virtual:kwisple-astro/options carrying your options;
  5. with on-demand rendering (output: "server" or an adapter): injects the routes /api/preview, /api/revalidate and /api/not-found (not prerendered) and a middleware with order: "pre";
  6. injects /sitemap.xml (when sitemap.sections is set) and /robots.txt (unless robots: false), prerendered in static builds and rendered per request otherwise;
  7. warns in the terminal when KWISPLE_URL/KWISPLE_TOKEN are 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):

OptionTypeDefaultMeaning
sitemap{ sections: string[]; extra?: string[] } | falseno routeSections 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.
cacheTtlnumber3600Seconds the server keeps delivery API answers in memory. The webhook purges earlier. Off in astro dev.
stagingOpenstring[]noneStaging 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-foundPaths 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> with organizationJsonLd and webSiteJsonLd (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/

FileWhat
CmsLink.astroA 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.astroRich text from the editor with set:html in a .prose-cms wrapper.
NotFound.astroThe 404 content with <NotFoundBeacon /> from the SDK components.
Header.astrocms(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.astroGlobals 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.json extends astro/tsconfigs/strict, adds noUncheckedIndexedAccess and the alias @/* → src/*, and includes .astro/types.d.ts.
  • package.json scripts: dev (astro dev --port 4104), build, preview (astro preview --port 4104, loads .env through the integration), start (node --env-file-if-exists=.env ./dist/server/entry.mjs, the production Node server), typecheck (astro check) and kwisple.
  • .gitignore: node_modules, dist, .astro, .env, .env.local.
  • .env.example: the five variables with local defaults; kwisple link fills .env. In production set them in the host's environment (Environment variables and tokens).

How Astro differs from Next, in short

TopicNext starterAstro starter
ClientcreateClient({ url, token }) from @kwisple/sdk; getClient(client) from @kwisple/sdk/nextcreateKwispleClient({ schema }) and getClient(client, Astro) from @kwisple/sdk/astro/server; env read lazily through astro:env
Routes for preview, revalidate, not-foundFiles in src/app/api/* calling SDK factoriesInjected by the integration; paths configurable with routes
CacheNext data cache, tag kwisple:all, revalidate 3600 sIn-memory per process, cacheTtl 3600 s, off in dev
Resolver[[...path]]/page.tsx + handleNotFound[...path].astro + resolveEntry
SEO headmetadataFor() → Next Metadata<Seo> component (same resolveSeo rules)
ImagesimageProps() + next/image<Image> component on astro:assets <Picture>
Sitemap / robotsapp/sitemap.ts, app/robots.ts with createSitemap/createRobotsIntegration 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 gatestagingGate in proxy.tsThe integration's middleware
AI-agent reportingmaybeReportAiAgent in proxy.tsThe integration's middleware
Productionnext start, or a standalone Docker imagenode ./dist/server/entry.mjs (Node adapter), or a static build on a host with a deploy hook

Last updated 2026-10-30