PotaLab
← All examples
SnippetTenantsAuthRLSNext.js

Multi-tenant SaaS starter

Enable tenants in the dashboard (Auth, Tenants). Access tokens then carry the active tenant, and policies read it through auth.tenant_id(). A restrictive policy guarantees that no later permissive policy can cross tenants.

Without tenants enabled for the project, every tenants call returns error.code "tenants_disabled".

localhost:3000/workspace

ann@acme.com

Projects

  • Website
  • Mobile app

Members

  • Ann Leeowner
  • Bao Tranadmin
  • Chi Nguyenmember

Switching issues new tokens with a different tenant_id; RLS does the rest.

Preview · sample data
'use client'
import { useEffect, useState } from 'react'
import type { Tenant, TenantMember } from '@potalab/base'
import { createBrowserClient } from '@potalab/base/next'
import { useMutation, useQuery } from '@potalab/base/react'
const base = () =>
createBrowserClient({ url: process.env.NEXT_PUBLIC_POTALAB_BASE_URL!, key: process.env.NEXT_PUBLIC_POTALAB_BASE_KEY! })
export function TenantSwitcher() {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
return mounted ? <Workspace /> : <p>Loading...</p>
}
function Workspace() {
const client = base()
const [tenants, setTenants] = useState<Tenant[]>([])
const [active, setActive] = useState<string | null>(null)
const [members, setMembers] = useState<TenantMember[]>([])
const [name, setName] = useState('')
// RLS scopes rows to the active tenant (auth.tenant_id() in the token)
const projects = useQuery(active ? client.from('projects').select('id,name').order('created_at') : null)
const add = useMutation((n: string) => client.from('projects').insert({ name: n }))
useEffect(() => {
client.auth.tenants.list().then(({ data }) => {
if (data) {
setTenants(data.tenants)
setActive(data.active_tenant_id)
}
})
}, [])
useEffect(() => {
if (!active) return
client.auth.tenants.members(active).then(({ data }) => setMembers(data ?? []))
}, [active])
async function switchTo(id: string) {
// New tokens carry tenant_id + tenant_role
const { error } = await client.auth.tenants.switch(id)
if (error) return
setActive(id)
void projects.refetch()
}
return (
<div>
<label>
Workspace
<select value={active ?? ''} onChange={(e) => switchTo(e.target.value)}>
{tenants.map((t) => (
<option key={t.id} value={t.id}>{t.name} ({t.role})</option>
))}
</select>
</label>
<h2>Projects</h2>
<ul>
{projects.data?.map((p) => <li key={p.id}>{p.name}</li>)}
</ul>
<form
onSubmit={(e) => {
e.preventDefault()
add.mutate(name)
setName('')
}}
>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="New project" />
<button type="submit" disabled={!name.trim()}>Add</button>
</form>
<h2>Members</h2>
<ul>
{members.map((m) => (
<li key={m.user_id}>
{m.name ?? m.email} <span>{m.role}</span>
</li>
))}
</ul>
</div>
)
}

Snippet for illustration, written for the Next.js App Router with @potalab/base/next,@potalab/base/react and @potalab/base. Set NEXT_PUBLIC_POTALAB_BASE_URL andNEXT_PUBLIC_POTALAB_BASE_KEY, adapt names and redirect URLs, and review policies before production.

Next: File upload with Storage →