Kwisple
Browse the docs

Your site repository › Rendering

Rendering content

Turn a request path into a typed entry and render it with views, the page builder, rich text, images, links, navigation and globals, with redirects and 404s handled for you, in Next.js and Astro.

A Kwisple site is a resolver plus a set of components. Every URL that is not a code route goes through one catch-all route: the path becomes an entry, the entry type picks a view, and the view renders fields, blocks, images and links from the delivery API. This page walks through each piece in the order a request meets it, for both starters.

StepNext.js (@kwisple/sdk/next)Astro (@kwisple/sdk/astro/*)
ClientcreateClient in src/lib/cms.ts, getClient(client) per requestcreateKwispleClient in src/lib/cms.ts, getClient(client, Astro) per request
Resolversrc/app/[[...path]]/page.tsx with entryByPathsrc/pages/[...path].astro with resolveEntry
Viewssrc/views/entry-view.tsx (switch (entry.type))src/views/EntryView.astro
Page buildersrc/builder/builder.tsxsrc/builder/Builder.astro
Images<CmsImage> around imageProps()<Image> from @kwisple/sdk/astro/components
404 and redirectshandleNotFound, <NotFoundBeacon />, createNotFoundRouteresolveEntry, <NotFoundBeacon />, injected /api/not-found

The client

Both starters create one typed client in src/lib/cms.ts, typed by your content model so entry.type, field values and block types are checked by TypeScript without a codegen step.

// Next.js: src/lib/cms.ts
import { 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: process.env.KWISPLE_URL!,
  token: process.env.KWISPLE_TOKEN!,
});
export const cms = () => getClient(client); // per request: drafts while previewing
export type SiteEntry = Entry<typeof schema>;
// Astro: src/lib/cms.ts
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);

The starter's Next.js version wraps process.env in a small env() helper that throws a Dutch message telling you to run kwisple login and kwisple link when a variable is missing.

createClient options

OptionTypeDefaultMeaning
urlstringrequiredThe delivery API (KWISPLE_URL). A trailing slash is removed.
tokenstringrequiredThe site API token (KWISPLE_TOKEN) with the read scope, plus preview for live preview.
previewstring | nullnoneA preview token. Set by withPreview() and getClient(); you rarely pass it yourself.
fetchtypeof fetchglobal fetchYour own fetch implementation.
revalidatenumber3600Seconds the Next data cache keeps an answer. The publish webhook purges earlier.
schemaEnv"dev" | "production"KWISPLE_SCHEMAdev reads with the development model that kwisple dev pushes (header x-kwisple-schema: dev).
schemayour cms.config valueoffTurns on the model check: before the first answer the client compares the hash of your model with the one the platform serves.
onSchemaMismatch"warn" | "throw""warn"warn logs one line; throw fails the build with a KwispleError (schema_mismatch).

createKwispleClient (Astro) takes url, token, fetch, schema and onSchemaMismatch. It reads KWISPLE_URL and KWISPLE_TOKEN from astro:env lazily on first use, so importing it never throws during astro check, and it wraps fetch in the in-memory content cache (off in astro dev). Every method is described in SDK client.

Base client or per-request client

Use the per-request client (await cms() in Next, cms(Astro) in Astro) for anything that should show the editor's draft during live preview: the resolver, views, listings. Use the base client (client) for things that are the same for every visitor and must stay cacheable: the root layout, header, footer, sitemap.ts, robots.ts, route handlers. In Next, getClient reads draftMode() and a cookie; calling it in a statically cached route is fine, but headers() and cookies() in a layout would make every page dynamic. See Live preview and revalidation.

Resolving a path

client.entryByPath(path) turns a request path into the entry that lives there. It fetches the site's locales once (client.site()), strips a locale prefix when the first segment is a non-default locale, converts the rest to a URI (/ becomes __home__) and asks for that entry.

const { entry, locale, uri } = await api.entryByPath("/fr/a-propos");
// entry: the typed entry or null · locale: "fr" · uri: "a-propos"
ReturnedMeaning
entryThe live entry at that URI in that locale (the draft when previewing), or null.
localeThe locale from the prefix, else the site's default locale.
uriThe URI that was looked up (__home__ for the homepage).

Query strings and hashes are ignored. The default locale has no prefix; other locales live under /<locale>/…. URI formats per section and locale rules are in Localisation and URIs.

Next.js resolver

// src/app/[[...path]]/page.tsx
import { handleNotFound, metadataFor } from "@kwisple/sdk/next";
import { client, cms } from "@/lib/cms";
import { EntryView } from "@/views/entry-view";

export const dynamicParams = true;
export function generateStaticParams() { return []; }

type Props = { params: Promise<{ path?: string[] }> };
const toPath = (s?: string[]) => `/${(s ?? []).map(encodeURIComponent).join("/")}`;

export default async function CatchAllPage({ params }: Props) {
  const path = toPath((await params).path);
  const api = await cms();
  const { entry } = await api.entryByPath(path);
  if (!entry) return handleNotFound(api, decodeURI(path), { report: false });
  return <EntryView entry={entry} />;
}

generateStaticParams() returning [] with dynamicParams = true means every page renders on its first request and is then served from the cache until a publish purges it (ISR). generateMetadata in the same file calls metadataFor(entry, …); see SEO, performance and security headers.

Astro resolver

---
// src/pages/[...path].astro
import { resolveEntry } from "@kwisple/sdk/astro/server";
import NotFound from "@/components/NotFound.astro";
import Base from "@/layouts/Base.astro";
import { client } from "@/lib/cms";
import EntryView from "@/views/EntryView.astro";

const r = await resolveEntry(Astro, client);
if (r.response) return r.response;          // a dashboard redirect (or a 410)
if (r.notFound) Astro.response.status = 404;
---
{r.entry ? <Base entry={r.entry}><EntryView entry={r.entry} /></Base> : <Base title="Pagina niet gevonden" noindex><NotFound /></Base>}

resolveEntry(Astro, client) returns { entry, locale, uri, api, response, notFound }. api is the per-request client it used, so you can keep reading drafts with it.

Views per entry type

The resolver hands the entry to one component that switches on entry.type. Because Entry<Schema> is a discriminated union, each branch gets a fully typed entry: entry.heroImage, entry.builder and so on exist only on the types that have them.

// src/views/entry-view.tsx
export function EntryView({ entry }: { entry: SiteEntry }) {
  switch (entry.type) {
    case "home": return <HomeView entry={entry} />;
    case "page": return <PageView entry={entry} />;
    case "newsArticle": return <NewsView entry={entry} />;
  }
}

In Astro, EntryView.astro does the same with one line per type ({entry.type === "page" ? <PageView entry={entry} /> : null}). A new entry type means a new view file and a new branch. A section with several entry types simply gets several branches. Use Entry<Schema, "news"> for the entries of one section, as the starters' NewsEntry does.

The page builder

The builder renders a Matrix field (the starters call it builder) block by block. Each block is a nested entry with id, type and its own fields.

// src/builder/builder.tsx (excerpt)
import { blockAttrs } from "@kwisple/sdk";

export function Builder({ blocks }: { blocks: BuilderBlock[] }) {
  return blocks.map((block, i) => (
    <div key={block.id} {...blockAttrs(block.type.replace(/^block([A-Z])/, (_, c) => c.toLowerCase()), i)}>
      <BuilderBlockView block={block} />
    </div>
  ));
}

function BuilderBlockView({ block }: { block: BuilderBlock }) {
  switch (block.type) {
    case "blockText": return <TextBlock block={block} />;
    case "blockMedia": return <MediaBlock block={block} />;
    // …one case per block type
    default: return assertNever(block);
  }
}

blockAttrs(type, index) adds data-kwisple-block="<position>:<type>" (position counted from 1, for example 2:cta). The statistics script uses it to tell which blocks visitors saw and what they clicked in them; without it, it falls back to the <section> elements inside <main>.

The assertNever(block: never) default makes a block type without a case a type error. Astro gets the same guarantee from satisfies Record<BuilderBlock["type"], unknown> on its component map:

// src/builder/Builder.astro (frontmatter excerpt)
const components = {
  blockText: Text,
  blockMedia: Media,
  // …
} satisfies Record<BuilderBlock["type"], unknown>;

Adding a block

  1. Define the entry type in cms/blocks.ts with defineEntryType and add it to the entryTypes of the builder Matrix field. kwisple dev pushes it to the development model; the block appears in the editor's block picker.
  2. Create the component. Next: src/builder/blocks/quote.tsx typed with BlockProps<"blockQuote"> from @/lib/cms. Astro: src/builder/blocks/Quote.astro with block: BlockOfType<"blockQuote"> in its Props.
  3. Register it. Next: case "blockQuote": return <QuoteBlock block={block} />; in builder.tsx. Astro: blockQuote: Quote in components in Builder.astro. Until you do, tsc / astro check fails.
  4. Add the block to a page in the dashboard and watch the live preview.
  5. Promote the model with kwisple push (or let CI do it).

The model side (options, nesting, stored shape) is in Blocks and relations.

Rich text

A richText field arrives as an HTML string from the editor's limited toolbar. The platform sanitises it when the entry is published, so you can render it directly.

// Next: src/components/rich-text.tsx
export function RichText({ html, className }: { html: string; className?: string }) {
  if (!html) return null;
  return <div className={`prose-cms ${className ?? ""}`} dangerouslySetInnerHTML={{ __html: html }} />;
}
<!-- Astro: src/components/RichText.astro -->
{html ? <div class:list={["prose-cms", className]} set:html={html} /> : null}

.prose-cms in the starters' CSS styles headings, lists and links inside it. Sanitise again yourself only if untrusted users can write content. Field options are in Fields reference.

Images

An assets field resolves to Asset[]. Each asset has url, kind (image, svg, video, document), mimeType, width, height, alt, focalPoint ({ x, y } from 0 to 1, or null for the centre) and placeholder (a tiny base64 image for blur-up, or null). The platform refuses to publish an image without alt text.

Next.js: imageProps and <CmsImage>

imageProps(asset, { sizes, priority }) returns props for next/image: src, alt, width, height, sizes (default "100vw"), placeholder: "blur" with blurDataURL when the asset has a placeholder, and style.objectPosition from the focal point ("50% 50%" without one). priority sets loading="eager" and fetchPriority="high"; everything else stays lazy.

<CmsImage asset={entry.image[0]} sizes="(min-width: 1024px) 50vw, 100vw" priority />
<CmsImage asset={card.image[0]} sizes="(min-width: 768px) 33vw, 100vw" className="aspect-[4/3]" fill />

The starter's <CmsImage asset sizes priority className fill> renders SVGs, assets without dimensions and fill images inside a relative wrapper with fill and object-cover (SVGs unoptimized), and everything else with fixed width/height. next.config.ts allows the platform's asset host from KWISPLE_ASSET_ORIGIN and serves AVIF and WebP.

Astro: <Image>

---
import { Image } from "@kwisple/sdk/astro/components";
---
<Image asset={entry.image[0]} sizes="(min-width: 768px) 720px, 100vw" priority class="aspect-[16/9] w-full object-cover" />
PropMeaning
assetThe Asset.
sizesDefault "100vw". Always pass a real value for images narrower than the viewport.
widthsOverride the width ladder (default 360, 540, 720, 960, 1280, 1600, 1920, 2560; never wider than the original).
priorityThe LCP image only: eager loading and fetchpriority="high".
class, pictureClassClasses on the <img> and on the <picture>.
altOverride the asset's alt text ("" for a decorative duplicate).

Bitmaps become a <Picture> with AVIF and WebP through astro:assets; SVG, GIF and non-images render as a plain <img>. The focal point becomes object-position, and the placeholder a blurred background image. imageSources(asset, options) from @kwisple/sdk/astro/server returns the same values if you build your own markup.

Rules for both: priority for the first hero or cover image only, a real sizes everywhere, and keep width/height so nothing shifts. See SEO, performance and security headers.

A link field resolves to a Link or null:

PropertyMeaning
typeurl, entry, asset, email or phone.
hrefReady to use: /over-ons, an absolute URL, mailto:…, tel:… or the asset's URL.
textThe link text the editor typed.
newWindowThe editor asked for a new window.
entry, assetThe linked entry (EntryRef) or asset, for the matching types.

The starters' CmsLink renders entry links with the framework's client-side link (next/link) unless they open in a new window, and everything else as <a>. Asset links always open in a new window. New-window links get target="_blank", rel="noopener" and a visually hidden "(opent in een nieuw venster)"; external URLs get rel="noopener".

{entry.link ? <CmsLink link={entry.link} className={buttonClass} /> : null}

In Astro, <CmsLink link={…}> renders the slot when you give one, else link.text. Link kinds per field are configured in the model (Fields reference).

client.tree(section, { locale?, maxLevel? }) returns the structure of a structure section as nested TreeNodes, ready for menus:

PropertyMeaning
id, section, type, title, slugThe entry.
uriIts URI, or null.
urlPublic path with the locale prefix (/over-ons, /fr/a-propos), or null.
childrenChild nodes.
const tree = await client.tree("pages", { maxLevel: 2 });

The starters' header renders the top level with a dropdown per child, adds code routes such as /nieuws by hand, and (Astro) marks the current item with aria-current="page". For a multilingual site pass the locale from entryByPath.

Globals

Global sets are site-wide fields (contact details, footer texts). client.globals(locale?) returns all of them at once, keyed by handle and typed by your model:

const { footer, contact } = await client.globals();

Shared fields have one value; translatable fields return the value for the locale you pass (the default locale without one). When several components need the same globals in one request, wrap the call in React's cache() as RANSON.N's siteData() does, so the layout, menu and footer share one fetch.

404 handling and redirects

When no entry matches, a redirect from the dashboard (Doorverwijzingen, redirects) wins; otherwise the site renders its 404 and logs it under Niet-gevonden pagina's (pages not found). URI changes on publish create redirects automatically (Localisation and URIs).

Next.js

  • In the resolver, handleNotFound(api, path, { report: false }) asks matchRedirect(path). A 301 or 308 becomes permanentRedirect, other codes redirect. Without a match it calls notFound(), which renders app/not-found.tsx. It never returns.
  • Reporting stays out of the cached route: { report: false } skips the server-side report, and <NotFoundBeacon /> in not-found.tsx sends the path and referrer from the browser (sendBeacon, else fetch) to /api/not-found. That route is export const POST = createNotFoundRoute(client); it forwards the report with the site token (paths and referrers are cut at 1000 characters; anything not starting with / gets a 400). Bots that run no JavaScript are not logged.
  • Before rendering, optionally: withRedirects(client, req) in proxy.ts. The starter calls it only when KWISPLE_REDIRECTS_IN_PROXY=1, because it costs one API call per page request. Use it when a redirect must win over a page that still exists. It skips /api/, /_next/ and file paths, answers 410 with "Deze pagina bestaat niet meer." for a 410 redirect, and redirects with the stored status otherwise.

Astro

resolveEntry follows a redirect itself (r.response, with a 410 answered as above) and sets notFound when there is nothing. The starter then renders NotFound.astro, which contains <NotFoundBeacon />; the integration injects /api/not-found. src/pages/404.astro renders the same component for paths Astro itself does not route.

Caching and staging

matchRedirect is cached too, including misses, so a 404 page stays cheap: redirects created by a publish are purged with the site tag, redirects added by hand apply within five minutes. On a staging deployment (KWISPLE_ENV=staging) 404 reports are switched off, because testers click around unfinished pages (Staging).

Code routes next to the resolver

Pages that are code rather than entries (a news listing, a contact form) are ordinary routes: app/nieuws/page.tsx or src/pages/nieuws/index.astro. They query with the per-request client, need their own title, description and canonical, and must be listed in the sitemap's extra (SEO, performance and security headers). Querying is covered in Querying entries.

Last updated 2026-10-30