The Next.js starter (starters/next in the platform repository, copied by npm create kwisple-site -- --framework next) is a Next.js 16 App Router site with React 19, Tailwind v4 and @kwisple/sdk. It runs on port 4102. This page walks through every file. The last section shows how the RANSON.N production site grew out of the same structure.
acme-site/
├── cms.config.ts the content model (sections, globals; forms and apps when you add them)
├── cms/
│ ├── fields.ts reusable fields (`common`)
│ └── blocks.ts page-builder block entry types + the `builder` Matrix field
├── src/
│ ├── lib/cms.ts the typed client, the per-request client and the entry/block types
│ ├── proxy.ts request proxy: AI-agent reporting, optional redirects (and the staging gate)
│ ├── app/
│ │ ├── layout.tsx root layout: brand CSS, header/footer, JSON-LD, live preview
│ │ ├── [[...path]]/page.tsx the catch-all resolver: path → entry → view (ISR)
│ │ ├── __preview/page.tsx preview of entries that have no URL yet
│ │ ├── nieuws/page.tsx a code route: the news listing
│ │ ├── not-found.tsx the 404 page (+ beacon)
│ │ ├── api/preview/route.ts GET: live preview entry point
│ │ ├── api/revalidate/route.ts POST: signed publish webhook
│ │ ├── api/not-found/route.ts POST: 404 reports from the beacon
│ │ ├── sitemap.ts, robots.ts sitemap.xml and robots.txt from the platform
│ │ ├── icon.tsx favicon generated from the brand kit
│ │ └── globals.css Tailwind + brand tokens mapped onto Tailwind
│ ├── views/ one component per entry type, chosen by entry-view.tsx
│ ├── builder/ the page builder and one component per block
│ └── components/ cms-image, cms-link, rich-text, header, footer
├── .github/workflows/ content model in CI
├── .env.example, .gitignore, AGENTS.md
├── next.config.ts, postcss.config.mjs, tsconfig.json, package.jsonRoot files
cms.config.ts
The content model as code: the Craft project config, in TypeScript. It imports builder from cms/blocks.ts and common from cms/fields.ts, defines the entry types (home, page, newsArticle, footerLink) and exports defineSchema({ sections, globals }). Sections in the starter: home (single, uri: "__home__"), pages (structure, uri: "{parent.uri}/{slug}", maxLevels: 3) and news (channel, uri: "nieuws/{slug}", defaultOrder: "postDate desc"). Globals: contact (phone, email, address, opening hours table) and footer (text, links as a Matrix of footerLink, socials table).
Touch it whenever the content changes shape. kwisple dev pushes it on save; kwisple push promotes it. Forms (defineForm) and apps (defineApp) go in here too. See Schema as code.
cms/fields.ts
Reusable field definitions, the equivalent of Craft's shared field library: common.heading, common.intro, common.text (rich text, full toolbar), common.image (one image asset), common.caption, common.link (url, entry, email, phone, asset; entries from pages, news, home) and common.theme (a select rendered as buttons). Use the same object in many entry types and override per layout with .with({ label, required, width }).
cms/blocks.ts
The page-builder blocks: blockText, blockMedia, blockSplit, blockCards (with a nested card type), blockCta and blockFaq (with a nested faqItem whose title is derived with titleFormat: "{question}"). All have title: false, an icon (a Lucide name) and a description shown in the editor's block picker. At the bottom builder is the Matrix field shared by home and page. Adding a block starts here. The kwisple dev watcher follows every .ts file next to cms.config.ts, so cms/ is watched too.
.env.example and .gitignore
.env.example lists the variables with their local defaults (KWISPLE_URL=http://localhost:4101, KWISPLE_ASSET_ORIGIN=http://localhost:9100, KWISPLE_DASHBOARD_ORIGIN=http://localhost:4100, KWISPLE_REDIRECTS_IN_PROXY=0). You never fill .env by hand: kwisple link writes it. .gitignore ignores node_modules, .next, .env, .env.local, *.tsbuildinfo and next-env.d.ts. Every variable: Environment variables and tokens.
AGENTS.md
Written and re-added by next dev: a block that tells coding assistants to read the Next.js 16 docs in node_modules/next/dist/docs/ before writing code, because this Next version differs from older training data. Commit it with your work. create-kwisple-site does not copy it from the starter; your own next dev creates it.
next.config.ts
transpilePackages: ["@kwisple/sdk", "@kwisple/cms"]: the SDK ships TypeScript sources in the monorepo.images.remotePatternsbuilt fromKWISPLE_ASSET_ORIGIN, sonext/imagemay optimise the platform's assets;formats: ["image/avif", "image/webp"].images.dangerouslyAllowLocalIP: on outside production, or when the asset origin islocalhost/127.0.0.1, so a local production build (next build && next start) can still read local storage.poweredByHeader: false,reactStrictMode: true.
KWISPLE_ASSET_ORIGIN is read at build time here. Add headers() for security headers and long caching of public/ files as RANSON.N does (below, and in SEO, performance and security headers).
tsconfig.json, postcss.config.mjs, package.json
tsconfig.json is strict with noUncheckedIndexedAccess, moduleResolution: "bundler" (the SDK's declarations need it) and two path aliases: @/* → src/* and @cms/* → cms/*. It includes cms.config.ts and cms/**/*.ts so the model is type-checked with the site. postcss.config.mjs loads @tailwindcss/postcss. package.json scripts: dev (next dev -p 4102), build, start (next start -p 4102), typecheck and kwisple (so pnpm kwisple … works).
.github/workflows/kwisple.yml
The content-model workflow: on pull requests npx kwisple push --dry-run, on pushes to main npx kwisple push (exit 2 blocks destructive changes), and optionally curl -X POST "$KWISPLE_DEPLOY_HOOK" afterwards for a strict "schema first, then build" order. It needs the repository secrets KWISPLE_URL and KWISPLE_TOKEN (npx kwisple ci-token --gh sets them). See CI and automation.
src/lib/cms.ts: the client
import { type BlockOf, createClient, type Entry } from "@kwisple/sdk";
import { getClient } from "@kwisple/sdk/next";
import type schema from "../../cms.config";
export const client = createClient<typeof schema>({
url: env("KWISPLE_URL"),
token: env("KWISPLE_TOKEN"),
});
/** Per-request client (switches to draft content during live preview). */
export const cms = () => getClient(client);
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 BlockProps<T extends BuilderBlock["type"]> = { block: Extract<BuilderBlock, { type: T }> };Two clients: client is the base client for everything that is the same for every visitor (layout, sitemap, robots, the icon); cms() is the per-request client that switches to the previewed draft when Next draft mode is on. Use await cms() in pages and views, client in route handlers and metadata routes. The env() helper throws a Dutch error telling you to run kwisple login and kwisple link when a variable is missing. The exported types are what views and blocks take as props. Add schema to the options to get a warning when code and platform differ (Rendering content).
src/proxy.ts: the request proxy
Next 16's request proxy (the former middleware). The starter's version:
export async function proxy(req: NextRequest) {
maybeReportAiAgent(client, req); // counts GPTBot, ClaudeBot, … under "AI-bezoek"
if (process.env.KWISPLE_REDIRECTS_IN_PROXY !== "1") return;
return withRedirects(client, req); // optional: redirects before rendering
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.[a-z0-9]+$).*)"],
};Redirects are followed on a 404 anyway (handleNotFound in the catch-all), which costs nothing on normal requests. KWISPLE_REDIRECTS_IN_PROXY=1 checks them before rendering, one API call per page request, only needed when a redirect must win over a page that still exists. This is also where the staging gate goes (Staging).
src/app/: routing
layout.tsx
The root layout, a server component. It fetches client.site(), client.brand() and draftMode() in parallel and renders:
<html lang>from the site's default locale (nl→nl-BE);- font preloads from
brand.fontPreloads, or the Google Fonts stylesheet frombrand.fontsHref, and<style>{brand.css}</style>with the--brand-*variables; - a skip link,
<Header>(logo from the brand kit's primary logo) and<Footer>; <JsonLd>withorganizationJsonLdandwebSiteJsonLd;<LivePreview allowedOrigin={process.env.KWISPLE_DASHBOARD_ORIGIN} />only while draft mode is on.
generateMetadata sets title.template (%s · <site>) and metadataBase from the site URL. Keep request APIs (headers(), cookies()) out of the layout: they would turn every page dynamic. draftMode() is fine.
[[...path]]/page.tsx: the resolver
The optional catch-all: every URL that is not a code route lands here. dynamicParams = true and generateStaticParams() returning [] mean a page renders on its first request and is then served from the cache until a publish calls /api/revalidate (ISR, like Craft's Blitz). It builds the path from the segments, calls api.entryByPath(path), and either renders <EntryView entry={entry} /> (with a breadcrumbJsonLd for every page but the homepage) or calls handleNotFound(api, path, { report: false }), which follows a dashboard redirect or renders not-found.tsx. generateMetadata calls metadataFor(entry, { name, siteUrl, defaultDescription, titleTemplate }); the description falls back to the entry's intro because a root-level optional catch-all does not inherit the layout's title.template.
__preview/page.tsx
/__preview?entry=…&locale=…: live preview for entries that were never published and therefore have no URL yet. The platform's preview verification returns this path for such entries. It only works in draft mode (api.options.preview set); otherwise notFound(). Marked robots: { index: false, follow: false }. On disk the folder is %5F%5Fpreview (URL-encoded __preview), because Next treats a leading underscore folder as private.
nieuws/page.tsx
A code route: the news listing. Shows how to query a channel (api.entries({ section: "news", orderBy: "postDate desc", limit: 24 })) and render cards with <CmsImage fill>. Code routes need their own metadata (title, description, alternates.canonical) and must be listed in sitemap.ts under extra.
not-found.tsx
The 404 page: <NotFoundBeacon /> reports the path and referrer from the browser to /api/not-found, so the page itself stays static. robots: { index: false }.
api/preview/route.ts, api/revalidate/route.ts, api/not-found/route.ts
Three one-liners around SDK factories:
export const GET = createPreviewRoute(client); // api/preview
export const POST = createRevalidateRoute({ secret: process.env.KWISPLE_WEBHOOK_SECRET }); // api/revalidate
export const POST = createNotFoundRoute(client); // api/not-foundPreview turns on draft mode and stores the token; revalidate verifies the signature and purges the kwisple:all tag plus the payload's tags; not-found forwards beacon reports to the platform's 404 log. Details: Live preview and revalidation.
sitemap.ts, robots.ts, icon.tsx
sitemap.ts:createSitemap(client, { sections: ["home", "pages", "news"], extra: ["/nieuws"], fallbackOrigin }). Every live, indexable entry with a URL, per locale, withlastModifiedand hreflang alternates. Add every section and every code route.robots.ts:createRobots(client, { fallbackOrigin }). Allows everything except/api/and/__preview; disallows everything while the site is not live or on a preview deployment.icon.tsx: a 32×32 PNG favicon generated withnext/ogfrom the brand kit's primary colour and the site name's first letter, so a new site never 404s on/favicon.ico. Replace withapp/icon.svgonce the client has a real icon.
globals.css
@import "tailwindcss" plus an @theme inline block that maps the platform's --brand-* variables onto Tailwind tokens: --color-primary: var(--brand-primary, #1d4ed8) and so on for secondary, accent, background, surface, text, muted, border, --font-heading, --font-body and --radius-brand. Headings take weight, tracking and case from --brand-heading-*. .prose-cms styles rich text. A reduced-motion block switches animations off. Change colours and fonts in the brand kit, not here.
src/views/: one view per entry type
entry-view.tsx is the Craft _resolvers/_views equivalent: a switch (entry.type) that renders <HomeView>, <PageView> or <NewsView>. Because SiteEntry is a discriminated union, each view receives a fully typed entry. home-view.tsx renders the hero (heading, intro, link, priority image) and the <Builder>; page-view.tsx a header, an optional hero image and the builder; news-view.tsx the article with a NewsArticle JSON-LD script, the body as <RichText> and the related entries. A new entry type means a new file here and a new case.
src/builder/: the page builder
builder.tsx renders the Matrix blocks. Each block is wrapped in a <div {...blockAttrs(name, i)}> (the data-kwisple-block="<position>:<type>" attribute the statistics use to tell which blocks visitors saw and clicked) and dispatched through a switch (block.type) with an exhaustive assertNever default. blocks/*.tsx hold one component per block, typed with BlockProps<"blockMedia"> and so on. faq.tsx shows a block emitting its own JSON-LD (FAQPage) and using native <details>; media.tsx shows sizes that depend on the block's width option.
src/components/
| File | What |
|---|---|
cms-image.tsx | <CmsImage asset sizes priority className fill>: next/image with imageProps(asset, { sizes, priority }) (alt, dimensions, blur placeholder, focal point as object-position, eager loading only with priority). SVGs and assets without dimensions render with fill and unoptimized. |
cms-link.tsx | <CmsLink link>: renders a Link field value as next/link for internal entry links, else <a> with target="_blank" + rel="noopener" for newWindow and asset links, plus a visually hidden "(opent in een nieuw venster)". Also exports buttonClass. |
rich-text.tsx | <RichText html className>: rich text from the editor (sanitised on publish by the platform) in a .prose-cms wrapper. |
header.tsx | Async server component: client.tree("pages", { maxLevel: 2 }) for the main navigation with a dropdown per level-2 child, the logo from the brand kit, a fixed "Nieuws" item. |
footer.tsx | Async server component: client.globals() → footer and contact (text, socials, address, opening hours, footer links as <CmsLink>). |
How a production site grows: RANSON.N
The RANSON.N repository (kwisple-ranson-n, a Next.js 16 site for an electrician, rebuilt from a Craft 5 project) keeps the starter's skeleton and adds what a real client needs. The differences are a good map of where things go.
kwisple-ranson-n/
├── Dockerfile, .dockerignore production image (standalone Next server on :4103)
├── vendor/ packed @kwisple/sdk, @kwisple/cms tarballs until they are on npm
├── cms.config.ts six sections, two globals, two apps (defineApp)
├── src/
│ ├── app/
│ │ ├── (site)/ route group: the public website
│ │ │ ├── layout.tsx site layout: fonts, cookie banner, analytics, live preview
│ │ │ ├── [[...path]]/page.tsx resolver (+ StructuredData)
│ │ │ ├── __preview/page.tsx, not-found.tsx, error.tsx
│ │ ├── (beheer)/ route group: tools shown inside the dashboard (apps)
│ │ │ ├── layout.tsx bare layout: house style only, no header, banner or analytics
│ │ │ └── beheer/boekingen/, beheer/golfgenerator/
│ │ ├── api/preview, api/revalidate, api/not-found as in the starter
│ │ ├── api/contact, api/afspraak form endpoints → client.submitForm
│ │ ├── api/beheer/ app endpoints, checked with appPlatform
│ │ ├── robots.ts, sitemap.ts, global-error.tsx
│ ├── lib/cms.ts + siteData(): globals and services cached per request with React cache()
│ ├── lib/beheer.ts app session helpers (appPlatform, tickets)
│ ├── lib/forms.ts forwardForm(), visitorIp()
│ ├── lib/seo.ts, lib/structured-data.tsx
│ ├── proxy.ts + stagingGate, lowercase redirect
│ ├── views/, components/, scripts/, css/- Route groups separate the website from the apps.
(site)has the full layout;(beheer)has a bare root layout (robots: { index: false }) because those pages render inside the dashboard's frame. Pages under/beheerconnect withconnectAppfrom@kwisple/sdk/app(seecomponents/beheer/app-sessie.tsx), and every/api/beheer/*route starts withappPlatform(request, { secret, app, dashboardOrigin })and answers 401 without a session. The apps are declared incms.config.ts(boekingen,golfgenerator). See Apps overview and Example: a booking desk. proxy.tsgrows:stagingGatewithopen: ["/beheer", "/api/beheer"](those paths check the app session themselves), a 308 redirect from mixed-case paths to lowercase, and the matcher now includes/api/so the gate covers it on staging.next.config.tsgrows:output: "standalone"whenNEXT_OUTPUT=standalone(set by the Dockerfile),outputFileTracingIncludesfor a file read at runtime, andheaders()with HSTS,X-Content-Type-Options,Referrer-Policy,Permissions-Policy,Content-Security-Policy: frame-ancestors 'self' <dashboard origin>and longCache-Controlfor/fonts/*(immutable) and/assets/*(a month).Dockerfile: a two-stage build onnode:22slim. The build stage installs with pnpm (includingvendor/), takesKWISPLE_ASSET_ORIGINas a build arg, and runspnpm buildwith the site's.env.productionmounted as a BuildKit secret (--secret id=site_env), so the token never lands in a layer. The runtime stage copies.next/standalone,.next/staticandpublic, runs asnodeon port 4103 withnode server.js..dockerignorekeeps.env*(except.env.example) out of the context. See Hosting and deploys.lib/cms.tsaddssiteData(): globals plus the services section, wrapped in React'scache()so the layout, the menu, the footer and the booking popup share one fetch per request.lib/seo.tsandlib/structured-data.tsx: a site-specificseoMetadata()(default description and default share image) and a<StructuredData>component that emits anElectricianLocalBusiness with opening hours from a global,WebSite, a breadcrumb and aServiceon service pages, all with the SDK's builders. See SEO, performance and security headers.- Forms:
api/contactandapi/afspraakvalidate on the server and callclient.submitFormwithslot(a booking moment only one submission may claim) andconfirm(a confirmation mail), reading the visitor's IP withvisitorIp(headers)fromX-Forwarded-Forbehind Caddy.
Everything that is specific to one client stays in that client's repository like this. Never add it to the platform.