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
| Option | Type | Default | Meaning |
|---|---|---|---|
dashboardOrigin | string | readonly string[] | undefined | required | The 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. |
timeoutMs | number | 10000 | How 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:
| Member | Meaning |
|---|---|
ready: Promise<AppSession> | Resolves with the first session. Rejects when the page is not inside the dashboard (see below). |
session(): AppSession | null | The current session claims, for display. Never a basis for access: the server decides. |
token(): string | null | The 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):
| Field | Type | Meaning |
|---|---|---|
v | 1 | Format version. |
app | string | The app's handle. |
site | string | Id of the site that declares the app. |
workspace | { id, slug, name } | The client. |
user | { id, name, email } | The person using the app. |
team | boolean | Someone 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. |
locale | string | The person's dashboard language (nl, fr, en). |
iat, exp | number | Issued 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
connectApp()checks that it runs in a frame (window.parent !== window) and that at least one origin was given. If not,readyrejects immediately.- 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. - The dashboard answers
{ type: "kwisple:app:session", token }to the frame, at the site's origin only.connectAppaccepts it only whene.originis one ofdashboardOriginande.sourceiswindow.parent. readyresolves, 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:
| Message | Cause |
|---|---|
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.localeis 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
nameanddescriptionabove 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.leveland return it with the data (RANSON.N returnskanBeslissen); 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 - 15remhigh (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.