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
Projects
- Billing portal
Members
- Dana Ortizowner
- Ann Leemember
Switching issues new tokens with a different tenant_id; RLS does the rest.
Preview · sample dataRendered from the code, no backend
'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> )}// app/workspace/page.tsx (Server Component)import { redirect } from 'next/navigation'import { revalidatePath } from 'next/cache'import { baseServer } from '@/lib/base-server'import { TenantSwitcher } from './TenantSwitcher'
export default async function WorkspacePage() { const base = await baseServer() const { data: { user } } = await base.auth.getUser() if (!user) redirect('/login')
const { data } = await base.auth.tenants.list()
// First visit: create a workspace (you become its owner) and make it active async function createWorkspace(form: FormData) { 'use server' const base = await baseServer() const { data: tenant } = await base.auth.tenants.create({ name: String(form.get('name')) }) if (tenant) await base.auth.tenants.switch(tenant.id) // new tokens carry the tenant revalidatePath('/workspace') }
if (!data?.tenants.length) { return ( <form action={createWorkspace}> <h1>Create your workspace</h1> <input name="name" placeholder="Acme" required /> <button type="submit">Create</button> </form> ) }
return ( <main> <h1>{user.email}</h1> <TenantSwitcher /> </main> )}// lib/base-server.ts// One client PER REQUEST: it holds the signed-in user's session.import { cookies } from 'next/headers'import { createServerClient } from '@potalab/base/next'
export async function baseServer() { const store = await cookies() return createServerClient({ url: process.env.NEXT_PUBLIC_POTALAB_BASE_URL!, key: process.env.NEXT_PUBLIC_POTALAB_BASE_KEY!, // lb_pub_... cookies: { getAll: () => store.getAll(), setAll: (list) => list.forEach((c) => store.set(c.name, c.value, c.options)), }, })}-- Enable tenants in the dashboard (Auth > Tenants). Tokens then carry tenant_id,-- and policies read it through auth.tenant_id().create table public.projects ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null default auth.tenant_id() references auth.tenants (id) on delete cascade, name text not null, created_at timestamptz not null default now());create index on public.projects (tenant_id);
alter table public.projects enable row level security;
-- RESTRICTIVE guard: AND-ed with every permissive policycreate policy projects_tenant_guard on public.projects as restrictive for all to anon, authenticated using (tenant_id = (select auth.tenant_id())) with check (tenant_id = (select auth.tenant_id()));
create policy projects_select_tenant on public.projects for select to authenticated using (tenant_id = (select auth.tenant_id()));create policy projects_insert_tenant on public.projects for insert to authenticated with check (tenant_id = (select auth.tenant_id()));create policy projects_update_tenant on public.projects for update to authenticated using (tenant_id = (select auth.tenant_id())) with check (tenant_id = (select auth.tenant_id()));create policy projects_delete_tenant on public.projects for delete to authenticated using (tenant_id = (select auth.tenant_id()) and (select auth.has_tenant_role('admin')));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.