Skip to content

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:

  1. Go to Settings > External Authentication
  2. Enable BYOT
  3. Add your Clerk JWKS URL: https://your-app.clerk.accounts.dev/.well-known/jwks.json
  4. Map Clerk claims:
  5. org_id -> workspace identifier
  6. org_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

  1. Workspace = tenant — Each customer organization maps to a Centrali workspace. Data is automatically isolated.
  2. BYOT = zero migration — Your users authenticate with Clerk. Centrali validates their tokens and enforces permissions.
  3. Policies = flexible RBAC — Define admin/member/viewer roles without custom middleware.
  4. Compute functions = serverless logic — Business logic runs inside Centrali, triggered by data events.
  5. Real-time = live UI — Subscribe to record changes for instant dashboard updates.