Kwisple
Browse the docs

Apps: custom modules per client

Declaring an app

The defineApp options, who gets to open an app and with which level, and how the app appears in the client's dashboard after a push.

An app is declared in cms.config.ts with defineApp and listed in defineSchema({ apps }). It travels with the content model: kwisple push (or kwisple dev, into the development model) makes the dashboard aware of it, and the menu item appears for everyone who may open it.

import { defineApp, defineSchema } from "@kwisple/sdk";

const boekingen = defineApp({
  handle: "boekingen",
  name: "Boekingen",
  description: "Aanvragen van de website bevestigen, afwijzen of een ander moment voorstellen.",
  icon: "calendar-check",
  path: "/beheer/boekingen",
  // Bookings hold names and addresses: the same people who read the form inbox.
  area: "website",
  level: "edit",
});

const golfgenerator = defineApp({
  handle: "golfgenerator",
  name: "Golfgenerator",
  description: "De pixelgolf uit het brandbook, als PNG of SVG in elk formaat.",
  icon: "waves",
  path: "/beheer/golfgenerator",
  area: "brand",
  level: "edit",
});

export default defineSchema({
  apps: [boekingen, golfgenerator],
  sections: [/* … */],
});

(These are the two apps of the RANSON.N site; see the booking desk and generator examples.)

defineApp options

defineApp(def: AppDef) returns the definition unchanged; it exists for typing (the handle becomes a literal type) and for readability. Validation happens when the schema is pushed.

OptionTypeDefaultMeaning
handlestringrequiredAddress segment in the dashboard: /w/<client>/apps/<handle>. Lowercase letter first, then lowercase letters, digits and -, at most 40 characters (^[a-z][a-z0-9-]{0,39}$). Must be unique within the schema.
namestringrequiredThe name in the navigation and in the page header above the app.
descriptionstring""One line under the name (page header and the Apps list).
iconstringnoneA Lucide icon name for the menu, e.g. "waves", "calendar-check". Any Lucide name works; without one the dashboard shows a generic icon.
pathstringrequiredThe app's page on the site, relative to the site's URL, e.g. /beheer/boekingen. Must start with a single / and may carry a query string; no fragment, no whitespace, never // (a path can never point the frame at another site).
areaAppArearequiredThe area of the client's access policy the app belongs to: website, stats, brand, media, social, studio or toolkit. Decides which client roles see it.
levelAppLevel"edit"The minimum level a client user needs in area: view < suggest < approve < edit < manage.
teamOnlybooleanfalseOnly the agency team sees the app. Clients never do, whatever their level.

Exported types: AppDef, AppArea, AppLevel, and the constant arrays appAreas and appLevels (from @kwisple/cms; AppDef, AppArea and AppLevel are re-exported by @kwisple/sdk). appLevelAtLeast(level, needed) compares two levels and is what your server uses to gate actions (see Server side).

Validation

kwisple push (and kwisple dev) validate the whole schema before sending it, apps included. These problems stop the push, each with a sentence naming the app:

  • a handle with characters outside [a-z0-9-], or starting with a digit or -;
  • the same handle declared twice in one schema;
  • an empty name;
  • a path that is not a path on the site (must start with /, no //, no spaces, no #);
  • an unknown area or level.

Who sees an app: areas, levels and appLevel()

Client rights on the platform come from the access policy the agency configures per client under Instellingen → Toegang ("Settings → Access"): for each role, a level per area. Apps plug into that same model, so a client never gets a new kind of right just for an app.

AreaDashboard labelLevels the agency can give
websiteWebsite-inhoudVerborgen, Bekijken, Voorstellen, Bewerken, Beheren
statsStatistiekenVerborgen, Bekijken
brandMerkVerborgen, Bekijken, Bewerken
mediaMediaVerborgen, Bekijken, Bewerken, Beheren
socialSocialVerborgen, Bekijken, Goedkeuren, Bewerken, Beheren
studioStudioVerborgen, Gebruiken (= edit)
toolkitHulpmiddelenVerborgen, Gebruiken (= edit)

Level keys: hidden (Verborgen), view (Bekijken), suggest (Voorstellen), approve (Goedkeuren), edit (Bewerken, shown as Gebruiken for Studio and Hulpmiddelen), manage (Beheren). hidden never opens an app. Pick an area whose available levels include the level you ask for: an app with area: "stats", level: "edit" can never be opened by a client, because no role gets edit in Statistieken.

appLevel(ctx, app) in the dashboard (apps/dashboard/src/lib/permissions.ts) returns the level a person works with in an app, or null when the app is not for them:

  1. Agency team (staff): always manage, for every app, teamOnly or not.
  2. Client users: teamOnly apps give null. Otherwise the person's level in app.area from their role; hidden or no grant gives null.
  3. Read-only subscription: while the client's subscription is read-only, the level is capped to view for everyone, staff included. An app that asks for more than view is then not available; an app with level: "view" opens with view.
  4. The result must reach app.level ?? "edit" (appLevelAtLeast), else null.

The level is put in the signed session (session.level), so your server sees the same value the dashboard used. team: true in the session marks agency staff.

Bekijk als klant ("View as client") applies here too: when a staff member views the dashboard as a client role, the apps and levels are those of that role. The apps API caps a request to the session's level, so the reduced rights hold on the server as well.

Choosing area and level

  • Does the app show personal data from forms? Use area: "website" with at least edit: the apps API also requires the form-inbox right (site.forms, which is Website-inhoud → Bewerken) for reading submissions, so a lower level would open an app that cannot load.
  • Is it a brand tool (generators, exports)? area: "brand", level: "edit" (or "view" if looking is enough).
  • Internal tooling for the agency? teamOnly: true; area is still required and only matters for the menu grouping of the declaration (clients never see it).

Pushing the schema so the app appears

npx kwisple push          # live model: the app appears for everyone who may open it
npx kwisple dev           # development model only: nothing changes for editors or clients

Apps are read from the live content model of the client's custom-code sites (mode = headless). An app declared only in the development model (what kwisple dev syncs) is not shown until kwisple push promotes it. Adding, renaming or removing an app is never a destructive schema change: it does not touch content and is never blocked.

The menu item and the URL

  • Every app gets its own menu item under Apps in the client's navigation, with its icon and name. The Apps page (/w/<client>/apps) lists them all with description; it is a 404 for people who may open none.
  • The app opens at /w/<client>/apps/<handle>, where <client> is the workspace slug. The page header shows name and description; the frame fills the rest of the screen (calc(100dvh - 15rem), at least 520 px high). Do not repeat the title inside your page.
  • Apps are also findable with ⌘K (Ctrl+K on Windows) by name.
  • Order: sites by name, then apps in the order of the apps array.

"Deze app is nog niet bereikbaar"

The frame needs an address and a secret to sign the session with. When either is missing the app is listed but opens on an empty state:

  • agency team: Deze app is nog niet bereikbaar — De website van deze app heeft nog geen adres of is nog niet gekoppeld. Vul het adres in bij Website → Instellingen en koppel de site met kwisple link.
  • clients: Probeer het later opnieuw of vraag het aan .

Causes, checked in workspaceApps() (apps/dashboard/src/lib/apps.ts):

  • the site has no webhook secret yet: kwisple link creates one the first time a project is linked; the dashboard also creates one when a publish webhook URL is saved under Website → Instellingen → Ontwikkeling;
  • the Site-URL is empty or not http(s);
  • path resolves to another origin than the site's (for example a path with a scheme).

Several sites declaring the same handle

A client can have several custom-code sites. Apps are collected per site, sites sorted by name, and the first site that declares a handle owns it: a second declaration of the same handle on another site is skipped silently. Give apps distinct handles across the client's sites, or accept that only one site's app is shown. Within one site a duplicate handle is a validation error.

Staging environments are never considered (listSites leaves them out), so the staging deployment's schema cannot shadow production's apps.

Read-only subscriptions

While a client's subscription has lapsed into read-only mode, the dashboard still shows apps, but only those whose level is view, and everyone opens them with level: "view". Design an app that should keep working in that state so its reads need view and its writes check appLevelAtLeast(session.level, "edit") on the server (see Server side): the app then degrades to looking instead of disappearing.

Checklist

  1. defineApp({...}) in cms.config.ts, added to defineSchema({ apps: [...] }).
  2. npx kwisple push from the site repo (CI does it on main, see CI).
  3. Site linked (kwisple link) and Site-URL filled in.
  4. The page exists at path and allows framing by the dashboard (Building the page).
  5. Open /w/<client>/apps/<handle> as a staff member, then as a client role with and without the level (Bekijk als klant).

Last updated 2026-10-30