SaaS Multi-tenant Backend¶
Build a multi-tenant SaaS backend using Centrali's workspace isolation, external authentication, role-based access control, and compute functions.
What You'll Build¶
A project management SaaS where each customer organization gets isolated data, their own users authenticate via Clerk, and compute functions handle business logic.
Features: - Workspace-per-tenant data isolation - Clerk BYOT authentication - Role-based access (admin, member, viewer) - Compute function for task assignment notifications
Architecture¶
Your SaaS App (Next.js)
│
├── Clerk (User Authentication)
│ └── JWT tokens with org claims
│
└── Centrali (Backend)
├── Workspace: tenant-acme
│ ├── Projects structure
│ ├── Tasks structure
│ └── Policies (admin/member/viewer)
│
└── Workspace: tenant-globex
├── Projects structure
├── Tasks structure
└── Policies (admin/member/viewer)
Step 1: Set Up Structures¶
Each tenant workspace needs the same data structures. Create them via the SDK:
import { CentraliSDK } from '@centrali-io/centrali-sdk';
// Admin SDK for workspace setup
const centrali = new CentraliSDK({
baseUrl: 'https://api.centrali.io',
workspaceId: 'tenant-acme',
clientId: process.env.CENTRALI_CLIENT_ID,
clientSecret: process.env.CENTRALI_CLIENT_SECRET
});
// Create Projects structure
await centrali.createStructure({
name: 'Project',
fields: {
name: { type: 'text', required: true },
description: { type: 'text' },
status: { type: 'text', enum: ['active', 'archived'] },
ownerId: { type: 'text', required: true }
}
});
// Create Tasks structure
await centrali.createStructure({
name: 'Task',
fields: {
title: { type: 'text', required: true },
description: { type: 'text' },
status: { type: 'text', enum: ['todo', 'in_progress', 'done'] },
priority: { type: 'text', enum: ['low', 'medium', 'high'] },
projectId: { type: 'text', required: true },
assigneeId: { type: 'text' }
}
});
Step 2: Configure External Auth (BYOT)¶
Set up Clerk as your identity provider. In your Centrali workspace dashboard:
- Go to Settings > External Authentication
- Enable BYOT
- Add your Clerk JWKS URL:
https://your-app.clerk.accounts.dev/.well-known/jwks.json - Map Clerk claims:
org_id-> workspace identifierorg_role-> user role
See External Auth (BYOT) and Clerk Integration for complete setup instructions.
Step 3: Create Access Policies¶
Set up role-based access using Centrali policies:
Admin Policy — Full access to all resources:
# Admins can do everything
curl -X POST "https://auth.centrali.io/workspace/tenant-acme/api/v1/permissions" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"resource": "records",
"actions": ["create", "retrieve", "update", "delete", "list"],
"policy": {
"conditions": {
"groups": { "contains": "admins" }
}
}
}'
Member Policy — Create and update, but not delete:
curl -X POST "https://auth.centrali.io/workspace/tenant-acme/api/v1/permissions" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"resource": "records",
"actions": ["create", "retrieve", "update", "list"],
"policy": {
"conditions": {
"groups": { "contains": "members" }
}
}
}'
Viewer Policy — Read-only:
curl -X POST "https://auth.centrali.io/workspace/tenant-acme/api/v1/permissions" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"resource": "records",
"actions": ["retrieve", "list"],
"policy": {
"conditions": {
"groups": { "contains": "viewers" }
}
}
}'
See Policies & Permissions for the full policy syntax.
Step 4: Build API Routes¶
In your Next.js app, create API routes that pass the Clerk user token to Centrali:
// app/api/projects/route.ts
import { auth } from '@clerk/nextjs/server';
import { CentraliSDK } from '@centrali-io/centrali-sdk';
export async function GET() {
const { getToken, orgSlug } = await auth();
const token = await getToken();
// SDK uses the Clerk token directly — Centrali validates it via BYOT
const centrali = new CentraliSDK({
baseUrl: 'https://api.centrali.io',
workspaceId: orgSlug!,
token: token!
});
const projects = await centrali.queryRecords('Project', {
sort: '-createdAt',
limit: 50
});
return Response.json(projects);
}
export async function POST(request: Request) {
const { getToken, orgSlug, userId } = await auth();
const token = await getToken();
const body = await request.json();
const centrali = new CentraliSDK({
baseUrl: 'https://api.centrali.io',
workspaceId: orgSlug!,
token: token!
});
const project = await centrali.createRecord('Project', {
name: body.name,
description: body.description,
status: 'active',
ownerId: userId
});
return Response.json(project, { status: 201 });
}
Step 5: Add a Compute Function¶
Create a compute function that sends a notification when a task is assigned:
async function run(event, context) {
const { api } = context;
// Only trigger on task updates where assigneeId changed
if (event.type !== 'record_updated') return;
const before = event.data.before;
const after = event.data.after;
if (before.assigneeId === after.assigneeId) return;
if (!after.assigneeId) return;
// Get the project name for the notification
const project = await api.getRecord(after.projectId);
// Send notification via Centrali's notification service
await api.sendNotification({
type: 'email',
to: after.assigneeId,
template: 'task-assigned',
data: {
taskTitle: after.title,
projectName: project.data.name,
priority: after.priority
}
});
return { notified: after.assigneeId };
}
Set up a trigger on the Tasks structure for record_updated events.
Step 6: Subscribe to Real-time Updates¶
Add live task updates to your dashboard:
'use client';
import { useEffect, useState } from 'react';
import { CentraliSDK } from '@centrali-io/centrali-sdk';
export function TaskBoard({ token, workspace }: { token: string; workspace: string }) {
const [tasks, setTasks] = useState([]);
useEffect(() => {
const centrali = new CentraliSDK({
baseUrl: 'https://api.centrali.io',
workspaceId: workspace,
token
});
// Fetch initial tasks
centrali.queryRecords('Task', { limit: 100 }).then(res => setTasks(res.data));
// Subscribe to live updates
const sub = centrali.realtime.subscribe({
structures: ['Task'],
onEvent: (event) => {
if (event.event === 'record_created') {
setTasks(prev => [event.data, ...prev]);
}
if (event.event === 'record_updated') {
setTasks(prev => prev.map(t => t.id === event.recordId ? event.data.after : t));
}
if (event.event === 'record_deleted') {
setTasks(prev => prev.filter(t => t.id !== event.recordId));
}
}
});
return () => sub.unsubscribe();
}, [token, workspace]);
return (
<div>
{tasks.map(task => (
<div key={task.id}>
<strong>{task.data.title}</strong> — {task.data.status}
</div>
))}
</div>
);
}
Key Takeaways¶
- Workspace = tenant — Each customer organization maps to a Centrali workspace. Data is automatically isolated.
- BYOT = zero migration — Your users authenticate with Clerk. Centrali validates their tokens and enforces permissions.
- Policies = flexible RBAC — Define admin/member/viewer roles without custom middleware.
- Compute functions = serverless logic — Business logic runs inside Centrali, triggered by data events.
- Real-time = live UI — Subscribe to record changes for instant dashboard updates.
Related Documentation¶
- Workspaces — How workspace isolation works
- External Auth (BYOT) — Configure your identity provider
- Clerk Integration — Step-by-step Clerk setup
- Policies & Permissions — Access control reference
- Compute Functions — Serverless business logic
- Real-time Events — Live data streaming