Kwisple
Browse the docs

Your site repository › Structure and configuration

Project structure (Next.js)

Every folder and file of the Next.js starter explained, what it does and when you touch it, plus how a grown production site (RANSON.N) extends it.

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

Root 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.remotePatterns built from KWISPLE_ASSET_ORIGIN, so next/image may optimise the platform's assets; formats: ["image/avif", "image/webp"].
  • images.dangerouslyAllowLocalIP: on outside production, or when the asset origin is localhost/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 from brand.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> with organizationJsonLd and webSiteJsonLd;
  • <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-found

Preview 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, with lastModified and 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 with next/og from the brand kit's primary colour and the site name's first letter, so a new site never 404s on /favicon.ico. Replace with app/icon.svg once 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/

FileWhat
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.tsxAsync 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.tsxAsync 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 /beheer connect with connectApp from @kwisple/sdk/app (see components/beheer/app-sessie.tsx), and every /api/beheer/* route starts with appPlatform(request, { secret, app, dashboardOrigin }) and answers 401 without a session. The apps are declared in cms.config.ts (boekingen, golfgenerator). See Apps overview and Example: a booking desk.
  • proxy.ts grows: stagingGate with open: ["/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.ts grows: output: "standalone" when NEXT_OUTPUT=standalone (set by the Dockerfile), outputFileTracingIncludes for a file read at runtime, and headers() with HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Content-Security-Policy: frame-ancestors 'self' <dashboard origin> and long Cache-Control for /fonts/* (immutable) and /assets/* (a month).
  • Dockerfile: a two-stage build on node:22 slim. The build stage installs with pnpm (including vendor/), takes KWISPLE_ASSET_ORIGIN as a build arg, and runs pnpm build with the site's .env.production mounted as a BuildKit secret (--secret id=site_env), so the token never lands in a layer. The runtime stage copies .next/standalone, .next/static and public, runs as node on port 4103 with node server.js. .dockerignore keeps .env* (except .env.example) out of the context. See Hosting and deploys.
  • lib/cms.ts adds siteData(): globals plus the services section, wrapped in React's cache() so the layout, the menu, the footer and the booking popup share one fetch per request.
  • lib/seo.ts and lib/structured-data.tsx: a site-specific seoMetadata() (default description and default share image) and a <StructuredData> component that emits an Electrician LocalBusiness with opening hours from a global, WebSite, a breadcrumb and a Service on service pages, all with the SDK's builders. See SEO, performance and security headers.
  • Forms: api/contact and api/afspraak validate on the server and call client.submitForm with slot (a booking moment only one submission may claim) and confirm (a confirmation mail), reading the visitor's IP with visitorIp(headers) from X-Forwarded-For behind Caddy.

Everything that is specific to one client stays in that client's repository like this. Never add it to the platform.

Last updated 2026-10-30