Build a multi-tenant SaaS dashboard end-to-end
A full walkthrough of the piece most low-code tools skip: real multi-tenancy. One prompt, an organizations + memberships data model with workspace-scoped RLS, a teammate invite flow, a nightly usage rollup, a Stripe webhook, and a custom domain. Every command below maps to a real Appbricx endpoint.
0. Prep
- Create a project (Getting started) using the blank template. Do not start from
saas-leads— that is a marketing-lead pack, not a product tenancy pack. - Attach BYOK if you have one: BYOK.
- Have your Stripe test-mode webhook signing secret ready. You will store it as a project secret, never in a JSON file.
1. The initial prompt
Build a multi-tenant SaaS dashboard.
Tenancy:
- organizations(id uuid pk, name text, slug text unique, plan text default 'free',
created_at timestamptz default now())
- memberships(id uuid pk, org_id uuid fk, user_id uuid, role text
check (role in ('owner','admin','member')),
created_at timestamptz default now(),
unique(org_id, user_id))
- Every data table has org_id and is RLS-scoped so users only see rows in
orgs where they have a membership.
Workspace:
- On signup, create an org named "{email}'s workspace" and a memberships
row with role='owner'. Store selected org id in the app session.
- /org/switch changes the active org.
UI:
- /login, /signup, /onboarding (name your workspace).
- /app is the dashboard with a members list, an invite form, and a usage
card that shows this month's api_calls total.
- /settings/billing shows the current plan (from organizations.plan) and a
Stripe checkout button.
Use runtime.api and named queries; no raw SQL in components.2. Data model & scoped RLS
The workspace-scoped policy is the whole game. Open .appbricx/backend/migrations/001_tenancy.sql:
CREATE TABLE IF NOT EXISTS organizations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
slug text UNIQUE NOT NULL,
plan text NOT NULL DEFAULT 'free',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS memberships (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id uuid NOT NULL,
role text NOT NULL CHECK (role IN ('owner','admin','member')),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (org_id, user_id)
);
CREATE TABLE IF NOT EXISTS invites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
email text NOT NULL,
role text NOT NULL DEFAULT 'member',
token text NOT NULL UNIQUE,
accepted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS usage_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
kind text NOT NULL, -- e.g. 'api_call'
amount integer NOT NULL DEFAULT 1,
at timestamptz NOT NULL DEFAULT now()
);
-- RLS: every row is visible only if the app user is a member of org_id.
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE invites ENABLE ROW LEVEL SECURITY;
ALTER TABLE usage_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY org_by_membership ON organizations
USING (EXISTS (
SELECT 1 FROM memberships m
WHERE m.org_id = organizations.id
AND m.user_id::text = current_setting('app.user_id', true)
));
CREATE POLICY mem_by_membership ON memberships
USING (EXISTS (
SELECT 1 FROM memberships m2
WHERE m2.org_id = memberships.org_id
AND m2.user_id::text = current_setting('app.user_id', true)
));
CREATE POLICY invites_by_membership ON invites
USING (EXISTS (
SELECT 1 FROM memberships m
WHERE m.org_id = invites.org_id
AND m.user_id::text = current_setting('app.user_id', true)
));
CREATE POLICY usage_by_membership ON usage_events
USING (EXISTS (
SELECT 1 FROM memberships m
WHERE m.org_id = usage_events.org_id
AND m.user_id::text = current_setting('app.user_id', true)
));Background: Auth & RLS. Every product table you add later should carry org_id and reuse this policy shape.
3. Onboarding — first workspace
Because RLS only lets a user see rows in orgs they are a member of, you need an admin-elevated named query to create the very first organization + membership atomically. The agent should generate:
-- @name bootstrap_workspace
-- @elevated
INSERT INTO organizations (name, slug) VALUES ($1, $2)
RETURNING id AS org_id;
INSERT INTO memberships (org_id, user_id, role)
VALUES ((SELECT id FROM organizations WHERE slug = $2),
current_setting('app.user_id', true)::uuid,
'owner');The signup screen calls this once, stores the new org id on the session, and drops the user on /app. Every subsequent read is naturally scoped by the policy above.
4. Invite a teammate
Two moving parts: create an invite row, and send the email. The invite row is created via auto CRUD — RLS makes sure you can only insert for an org you are a member of. The email is dispatched by a small workflow so it can retry.
// Client — /app/members "Invite" form
const token = crypto.randomUUID();
await runtime.api.create("invites", {
org_id: session.orgId,
email: form.email,
role: form.role,
token,
});
await runtime.workflows.invoke("send-invite-email", {
payload: { email: form.email, token, orgName: session.orgName },
});The workflow at .appbricx/backend/workflows/send-invite-email.workflow.js:
/** @param {import("@appbricx/runtime").WorkflowContext} ctx */
export async function run(ctx) {
const { email, token, orgName } = ctx.trigger.payload ?? {};
if (!email || !token) throw new Error("email and token required");
const base = await ctx.secrets.get("PUBLIC_APP_URL");
await ctx.messages.email({
to: email,
subject: `You're invited to ${orgName} on Appbricx`,
text: `Accept the invite: ${base}/invite/${token}`,
});
return { ok: true };
}The accept page validates invites.token, inserts amemberships row, and marks the invite accepted. Because both pages use the auto CRUD API, RLS enforces the tenancy boundary end to end.
5. Nightly usage rollup
Turn raw usage_events into a per-org monthly total that the billing screen can read cheaply. Ask the agent:
Add a workflow "usage-rollup" on cron "0 2 * * *":
- for each organization, sum usage_events.amount where kind='api_call'
in the current month, and upsert into usage_monthly(org_id, month, total).
- publish topic "billing.usage.rolled" with { org_id, total }.
Also add named queries usage_this_month and usage_monthly_upsert./** @param {import("@appbricx/runtime").WorkflowContext} ctx */
export async function run(ctx) {
const orgs = await ctx.queries.run("all_org_ids");
if (!orgs.ok) throw new Error(orgs.error?.message ?? "all_org_ids failed");
let rolled = 0;
for (const { id: orgId } of orgs.rows ?? []) {
const totalRow = await ctx.queries.run("usage_this_month", { org_id: orgId });
const total = totalRow.rows?.[0]?.total ?? 0;
await ctx.queries.run("usage_monthly_upsert", { org_id: orgId, total });
await ctx.topics.publish("billing.usage.rolled", { org_id: orgId, total });
rolled += 1;
}
ctx.log.info(`rolled up ${rolled} orgs`);
return { ok: true, rolled };
}Schedule manifest:
{
"id": "usage-rollup",
"cron": "0 2 * * *",
"timezone": "UTC",
"workflow": "usage-rollup",
"enabled": true
}Test it now with runtime.workflows.invoke("usage-rollup") or Backend → Test in the editor. Docs: Workflows · Webhooks & schedules.
6. Stripe webhook — subscription updates
Stripe POSTs events to a public URL. Register a webhook manifest, then write a workflow that flips the org's plan.
// .appbricx/backend/webhooks/stripe.json
{
"id": "stripe",
"name": "stripe",
"workflow": "on-stripe-event",
"secret_ref": "STRIPE_WEBHOOK_SECRET",
"enabled": true
}Put the actual signing secret into project env / vault under the name STRIPE_WEBHOOK_SECRET. Declare the name in secrets.refs.json — see Secrets & env.
The workflow at .appbricx/backend/workflows/on-stripe-event.workflow.js:
/** @param {import("@appbricx/runtime").WorkflowContext} ctx */
export async function run(ctx) {
const body = ctx.trigger.payload?.body ?? {};
const type = body.type;
const sub = body.data?.object;
if (!type || !sub) return { ok: true, skipped: true };
const orgId = sub.metadata?.org_id;
if (!orgId) throw new Error("missing metadata.org_id on subscription");
let plan = "free";
if (type === "customer.subscription.created" ||
type === "customer.subscription.updated") {
plan = sub.status === "active" ? (sub.items.data[0].price.nickname ?? "pro")
: "free";
} else if (type === "customer.subscription.deleted") {
plan = "free";
}
await ctx.api.update("organizations", orgId, { plan });
await ctx.topics.publish("billing.plan.changed", { org_id: orgId, plan });
return { ok: true, plan };
}Point Stripe at the public route:
# In Stripe dashboard → Developers → Webhooks
Endpoint URL: https://<subdomain>.apps.appbricx.com/hooks/<PROJECT_ID>/stripe
Signing secret: whsec_... ← paste this as STRIPE_WEBHOOK_SECRETVerify locally with a curl that mimics Stripe:
curl -sS -X POST "$ORIGIN/hooks/$PROJECT_ID/stripe" \
-H "content-type: application/json" \
-H "x-appbricx-webhook-secret: $STRIPE_WEBHOOK_SECRET" \
-d '{"type":"customer.subscription.updated","data":{"object":{
"status":"active",
"items":{"data":[{"price":{"nickname":"pro"}}]},
"metadata":{"org_id":"'$ORG_ID'"}
}}}'
# → { "ok": true, "runId": "…" }7. Live plan / usage on the dashboard
Both workflows publish topics, so the billing screen updates without a reload:
useEffect(() => {
const unsubPlan = runtime.topics.subscribe(
"billing.plan.changed",
(ev) => { if (ev.org_id === session.orgId) setPlan(ev.plan); },
);
const unsubUsage = runtime.topics.subscribe(
"billing.usage.rolled",
(ev) => { if (ev.org_id === session.orgId) setUsage(ev.total); },
);
return () => { unsubPlan(); unsubUsage(); };
}, [session.orgId]);SSE mechanics: Topics & CDC.
8. Publish & custom domain
- Editor → Deploy / Publish. You get
https://<subdomain>.apps.appbricx.comimmediately. - Settings → Custom Domain → add
app.yourdomain.com. - Create a
CNAMEfromapptosites.appbricx.com, wait for DNS, then click Verify. - Update Stripe's webhook endpoint to the new host — the platform key auto-allows the domain once it is active.
Full playbook (apex, Cloudflare, TLS pitfalls): Publish & domains.
Internal map (what just happened)
Browser (dashboard)
→ /__appbricx/api/v1/{organizations,memberships,invites,usage_events}
RLS: EXISTS(memberships where user_id = app.user_id)
→ /__appbricx/topics/*/subscribe (SSE plan + usage updates)
Stripe → /hooks/<PROJECT_ID>/stripe (secret verified)
→ on-stripe-event.workflow.js
→ ctx.api.update("organizations", ...) → billing.plan.changed
Schedule ticker (~15s tick)
→ 02:00 UTC → usage-rollup.workflow.js
→ aggregates + upserts + billing.usage.rolledbootstrap_workspace, and it only ever inserts one org + one membership for the current user. Every other read and write flows through the same tenant policy — that is what makes the app safe to hand to real customers.