Kwisple
Browse the docs

Apps: custom modules per client

Building the page

The app's page in your site, connectApp from @kwisple/sdk/app, the framing requirements and UI advice, with the RANSON.N components as examples.

The app is a page of your site at the path you declared. The dashboard shows it in an <iframe>; your page asks the dashboard for its session with connectApp() from @kwisple/sdk/app and sends that session along with every request to your own server. This page covers the browser side. The server side (verifying the session, acting on platform data) is in Server side.

connectApp

import { connectApp } from "@kwisple/sdk/app";

const app = connectApp({ dashboardOrigin: process.env.NEXT_PUBLIC_KWISPLE_DASHBOARD_ORIGIN });
const session = await app.ready;                       // who is using it, for which client
const res = await app.fetch("/api/beheer/boekingen");  // Authorization: Bearer <token> added

@kwisple/sdk/app is framework-free and dependency-free; it only uses window.parent.postMessage and fetch. Import it from client code only (it touches window).

Options

OptionTypeDefaultMeaning
dashboardOriginstring | readonly string[] | undefinedrequiredThe dashboard's origin(s), the value of KWISPLE_DASHBOARD_ORIGIN. Messages from any other origin are ignored, and the ready request is posted only to these origins. Trailing slashes are stripped. Pass a list when the agency also uses a white-label dashboard host.
timeoutMsnumber10000How long to wait for the dashboard before ready rejects.

The value has to reach the browser. In Next.js read KWISPLE_DASHBOARD_ORIGIN in a Server Component and pass it as a prop to the client component (what RANSON.N does), or expose it as NEXT_PUBLIC_KWISPLE_DASHBOARD_ORIGIN. The origin is not a secret.

The connection

connectApp() returns an AppConnection:

MemberMeaning
ready: Promise<AppSession>Resolves with the first session. Rejects when the page is not inside the dashboard (see below).
session(): AppSession | nullThe current session claims, for display. Never a basis for access: the server decides.
token(): string | nullThe current signed token, if you need to send it yourself (Authorization: Bearer <token>).
fetch(input, init?)fetch with Authorization: Bearer <token> set on the request headers (other headers from init are kept).
onSession(listener)Called with every renewed session; returns the unsubscribe function.
close()Stops listening and clears listeners. Call it when the component unmounts.

The session fields

ready resolves with the token's claims (AppSession, exported from @kwisple/sdk/app and @kwisple/cms):

FieldTypeMeaning
v1Format version.
appstringThe app's handle.
sitestringId of the site that declares the app.
workspace{ id, slug, name }The client.
user{ id, name, email }The person using the app.
teambooleanSomeone of the agency team (always manage), not of the client's business.
level"view" | "suggest" | "approve" | "edit" | "manage"The person's level in the app's area.
localestringThe person's dashboard language (nl, fr, en).
iat, expnumberIssued at / expires at, seconds since the epoch.

In the browser these claims are read without verification (readAppSession): good for "Aangemeld als Robbe" and for hiding buttons the person cannot use anyway, never for deciding what a request may do. Your server verifies the signature.

How the handshake works

  1. connectApp() checks that it runs in a frame (window.parent !== window) and that at least one origin was given. If not, ready rejects immediately.
  2. It posts { type: "kwisple:app:ready" } to the parent at each origin, right away and then every 500 ms, because the page may load before the dashboard listens.
  3. The dashboard answers { type: "kwisple:app:session", token } to the frame, at the site's origin only. connectApp accepts it only when e.origin is one of dashboardOrigin and e.source is window.parent.
  4. ready resolves, the polling stops, listeners fire.

Renewal

The session lives 15 minutes. The dashboard renews it every 10 minutes while the app stays open and posts the new token into the frame; connectApp swaps it so fetch() and token() always carry the newest one, and onSession listeners run. Nothing to do on your side, unless you cache the token somewhere (do not).

When access is lost

When renewal fails (the person lost the level, the app was removed from the schema, the site was unlinked) the dashboard stops renewing and replaces the frame with Deze app is gesloten omdat je sessie afliep of je geen toegang meer hebt. Laad de pagina opnieuw om verder te gaan. Your page is unloaded with the frame. If a request of yours is in flight, the server side answers 401/403 as soon as the token expires or the rights are re-checked; show a short "reload" message for those.

The rejection outside the dashboard

ready rejects with an Error in three cases:

MessageCause
connectApp: dashboardOrigin ontbreekt (KWISPLE_DASHBOARD_ORIGIN).No origin given (the env var did not reach the browser).
Deze pagina werkt enkel binnen het dashboard.The page is not in a frame: someone opened the URL directly.
Het dashboard gaf geen sessie door.In a frame, but no session within timeoutMs (wrong dashboardOrigin, or framed by something else).

A rejected ready that nobody awaits is swallowed internally, so it never surfaces as an unhandled rejection. Do handle it in your UI: show that the tool lives in the dashboard, and stop there. Never fall back to an unauthenticated mode.

Framing requirements

The dashboard embeds your page cross-origin. Three things must hold.

frame-ancestors with the dashboard origin

Your site must allow the dashboard to frame it. In Next.js, RANSON.N sets a Content-Security-Policy header on every response in next.config.ts:

async headers() {
  const dashboard = process.env.KWISPLE_DASHBOARD_ORIGIN?.replace(/\/$/, "");
  return [
    {
      source: "/:path*",
      headers: [
        {
          key: "Content-Security-Policy",
          value: `frame-ancestors 'self'${dashboard ? ` ${dashboard}` : ""}`,
        },
        // …
      ],
    },
  ];
}

This is the same requirement live preview has, so a site that already previews in the dashboard is already right. If the agency uses a white-label dashboard host, add that origin too (see Security model).

No X-Frame-Options

Do not send X-Frame-Options: DENY or SAMEORIGIN for the app's pages; browsers honour it over frame-ancestors and the frame stays blank. Check your host's defaults.

No cookies

The page runs as a third party inside the dashboard: browsers block or partition its cookies. Do not rely on a cookie session, CSRF cookies or localStorage for anything that matters. The app session in the Authorization header is your authentication; it was designed so the page needs nothing else. The dashboard's frame uses sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals" and allow="clipboard-write": scripts, forms, downloads (a generator saving a PNG), new windows and clipboard writes work; access to the dashboard page itself does not.

Staging

On a staging deployment the SDK's stagingGate protects every path with a password. Open the app's paths explicitly so the dashboard's frame is not met by the password page:

const gate = await stagingGate(req, {
  client,
  secret: process.env.KWISPLE_WEBHOOK_SECRET,
  open: ["/beheer", "/api/beheer"],
  clientIp: visitorIp(req.headers),
});

Apps are declared by production sites only, so this matters when you test the pages on staging by hand. See Staging.

UI advice

  • Use the dashboard's language. session.locale is the person's dashboard language. Format dates with it (new Intl.DateTimeFormat(session.locale, …)) and pick your strings by it, instead of hardcoding one locale.
  • No second title. The dashboard already shows name and description above the frame. Start with content.
  • Keep it leaf-sized. One tool, one screen, few states. The app should feel like a part of the dashboard, not a second application: no menus, no footer, no cookie banner, no statistics script. Give the app routes their own bare layout.
  • Show the level honestly. Compute "can decide" on the server from session.level and return it with the data (RANSON.N returns kanBeslissen); hide actions the person cannot take and say so in one line (Je kan hier enkel kijken.).
  • Brand, not dashboard chrome. The page is the client's: use the site's own fonts and colours. The dashboard provides the grey canvas around it.
  • Height. The frame is 100dvh - 15rem high (minimum 520 px) and scrolls inside; design for a tall column, not a full window.
  • Errors in plain language, and always a way out ("Herlaad de pagina").

Example: the RANSON.N components

RANSON.N wraps connectApp once and reuses it in both apps. Files are in the site repository under src/components/beheer/ and src/app/(beheer)/.

app-sessie.tsx: connect once, provide the session

"use client";

import { type AppConnection, type AppSession, connectApp } from "@kwisple/sdk/app";
import { createContext, useContext, useEffect, useState } from "react";

type Staat =
  | { status: "laden" }
  | { status: "klaar"; session: AppSession; app: AppConnection }
  | { status: "buiten"; reden: string };

const Context = createContext<{ session: AppSession; app: AppConnection } | null>(null);

/** The session of whoever uses this tool in the dashboard (see lib/beheer.ts). */
export function useAppSessie() {
  const v = useContext(Context);
  if (!v) throw new Error("useAppSessie buiten <AppSessie>");
  return v;
}

export function AppSessie({ dashboardOrigin, children }: { dashboardOrigin: string; children: React.ReactNode }) {
  const [staat, setStaat] = useState<Staat>({ status: "laden" });
  useEffect(() => {
    const app = connectApp({ dashboardOrigin });
    app.ready.then(
      (session) => setStaat({ status: "klaar", session, app }),
      (e: Error) => setStaat({ status: "buiten", reden: e.message }),
    );
    return () => app.close();
  }, [dashboardOrigin]);

  if (staat.status === "laden") return <p className="p-8 text-muted" role="status">Even laden…</p>;
  if (staat.status === "buiten")
    return (
      <div className="mx-auto max-w-md p-8">
        <h1 className="font-display text-3xl">Enkel via het dashboard</h1>
        <p className="mt-3 text-muted">
          Deze tool hoort bij het beheer van RANSON.N. Open hem via het dashboard, onder Apps.
        </p>
      </div>
    );
  return <Context.Provider value={{ session: staat.session, app: staat.app }}>{children}</Context.Provider>;
}

Three things to copy: close() on unmount, a loading state (the handshake takes a moment), and a calm message for the "opened outside the dashboard" case.

The page: a Server Component passes the origin

// src/app/(beheer)/beheer/boekingen/page.tsx
import { AppSessie } from "@/components/beheer/app-sessie";
import { Boekingen } from "@/components/beheer/boekingen";
import { DASHBOARD_ORIGIN } from "@/lib/beheer";

export default function BoekingenPage() {
  return (
    <AppSessie dashboardOrigin={DASHBOARD_ORIGIN}>
      <Boekingen />
    </AppSessie>
  );
}

export const dynamic = "force-dynamic";

DASHBOARD_ORIGIN is process.env.KWISPLE_DASHBOARD_ORIGIN with trailing slashes removed (src/lib/beheer.ts). The page is a Server Component; only AppSessie and the tool are client components.

A bare layout for the app routes

// src/app/(beheer)/layout.tsx
export const metadata: Metadata = {
  title: "Beheer · RANSON.N",
  robots: { index: false, follow: false },
};

export default function BeheerLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="nl">
      <body className="min-h-screen bg-paper font-sans text-ink antialiased">{children}</body>
    </html>
  );
}

The (beheer) route group has its own root layout: the house style, none of the site around it (no header, cookie banner or statistics), and noindex.

Using the connection in a tool

const { app, session } = useAppSessie();

const laad = useCallback(async () => {
  const r = await app.fetch("/api/beheer/boekingen");
  if (!r.ok) return setFout("De boekingen konden niet geladen worden. Herlaad de pagina.");
  setData((await r.json()) as Data);
}, [app]);

// …
<p className="mt-16 text-sm text-muted">Aangemeld als {session.user.name}.</p>

app.fetch is a plain fetch with the bearer added; the request goes to the site's own API route, which verifies it (Server side). The full component is walked through in Example: a booking desk.

Astro sites

The same module works in an Astro island or a plain <script>: connectApp has no React dependency. Render the app route without the site layout, set frame-ancestors in your middleware or host headers, and keep the staging gate's open list in the integration options.

Last updated 2026-10-30