How to Build a Multi-Tenant SaaS Dashboard with Lovable: Supabase RLS and Organization-Based Access
Why Multi-Tenancy Is the First Architecture Decision for SaaS
Every SaaS application serving business customers needs multi-tenancy — the ability for multiple organizations to use the same application while keeping their data completely isolated. A user at Company A must never see Company B’s data, even though both use the same application and database.
Most no-code and AI-generated apps skip this requirement. They build single-user applications where all data belongs to one account. This works for demos but breaks immediately when you need team features, organization-level billing, or enterprise sales (where IT administrators need to manage their company’s account).
Lovable with Supabase can build proper multi-tenancy because Supabase’s Row-Level Security (RLS) enforces data isolation at the database level — not in the application code. This means even if the frontend has a bug that queries without an organization filter, the database itself prevents cross-tenant data leakage. This is the industry-standard approach for SaaS security.
This guide builds a complete multi-tenant dashboard from scratch.
Step 1: Design the Data Model
Core Tables
Prompt Lovable to create the foundational schema:
"Create a Supabase database schema for a multi-tenant SaaS application with these tables: 1. organizations - id (uuid, primary key, default gen_random_uuid()) - name (text, not null) - slug (text, unique, not null) — URL-friendly identifier - plan (text, default 'free') — subscription plan - created_at (timestamptz, default now()) - updated_at (timestamptz, default now()) 2. memberships - id (uuid, primary key) - user_id (uuid, references auth.users, not null) - organization_id (uuid, references organizations, not null) - role (text, not null) — 'owner', 'admin', 'member' - created_at (timestamptz, default now()) - UNIQUE constraint on (user_id, organization_id) 3. invitations - id (uuid, primary key) - organization_id (uuid, references organizations, not null) - email (text, not null) - role (text, default 'member') - invited_by (uuid, references auth.users) - status (text, default 'pending') — 'pending', 'accepted', 'declined' - token (text, unique, not null) — for invitation links - expires_at (timestamptz, not null) - created_at (timestamptz, default now()) 4. projects (example tenant-scoped data) - id (uuid, primary key) - organization_id (uuid, references organizations, not null) - name (text, not null) - description (text) - status (text, default 'active') - created_by (uuid, references auth.users) - created_at (timestamptz, default now()) - updated_at (timestamptz, default now()) Every data table (projects and any future tables) MUST have an organization_id column. This is the tenant isolation key."
The Organization Context Pattern
Every query in the application must be scoped to the current organization. The pattern:
"Create a React context called OrganizationContext that: 1. Stores the current organization (id, name, slug, plan) 2. Stores the current user's role in that organization 3. Provides a switchOrganization function 4. Loads from URL parameter (/org/:slug/dashboard) or from the user's default organization 5. Is available throughout the app via useOrganization hook All data-fetching hooks must use the organization_id from this context. Never hardcode or guess the organization."
Step 2: Configure Row-Level Security
Why RLS Is Non-Negotiable
Application-level filtering (“WHERE organization_id = X” in your queries) is not sufficient for multi-tenant security. A single missed filter in one query exposes all tenant data. RLS enforces the filter at the database level — every query is automatically filtered, regardless of the application code.
RLS Policies
"Set up Supabase Row-Level Security policies:
For the organizations table:
- SELECT: users can see organizations they are members of
(check memberships table)
- UPDATE: only users with 'owner' or 'admin' role can update
- DELETE: only users with 'owner' role can delete
- INSERT: any authenticated user can create (they become owner)
For the memberships table:
- SELECT: users can see memberships for organizations they
belong to
- INSERT: only 'owner' or 'admin' of the organization can add
- UPDATE: only 'owner' can change roles
- DELETE: 'owner' or 'admin' can remove members (owner cannot
be removed)
For the projects table (and all future data tables):
- SELECT: users can see projects in their organizations
- INSERT: any member of the organization can create
- UPDATE: creator or admin/owner can update
- DELETE: creator or admin/owner can delete
The RLS policy for SELECT on projects should be:
auth.uid() IN (
SELECT user_id FROM memberships
WHERE organization_id = projects.organization_id
)
Enable RLS on ALL tables. No table should be accessible
without a policy."
Testing RLS Policies
"Create a test page (only visible in development) that: 1. Shows the current user's ID and organization memberships 2. Attempts to query projects from a different organization (should return empty, not error) 3. Attempts to insert a project into a different organization (should fail) 4. Shows RLS policy status for each table This helps verify that data isolation is working correctly."
Step 3: Build the Organization Switcher
"Create an organization switcher component in the top navigation bar that: 1. Shows the current organization name and logo 2. Clicking opens a dropdown with all organizations the user belongs to 3. Each organization shows: name, user's role, member count 4. Clicking an organization switches the context and navigates to /org/:slug/dashboard 5. At the bottom of the dropdown: 'Create new organization' button 6. Use shadcn/ui Popover and Command components When switching organizations: - Update the OrganizationContext - Update the URL to reflect the new org slug - Reload data for the new organization - Clear any cached data from the previous organization"
Creating a New Organization
"When 'Create new organization' is clicked: 1. Show a modal with: Organization name, URL slug (auto- generated from name, editable) 2. On submit: a. Create the organization in Supabase b. Create a membership with role 'owner' for the current user c. Switch to the new organization d. Navigate to /org/:slug/dashboard 3. The slug must be unique — check availability before submit"
Step 4: Implement Role-Based Access
Role Hierarchy
Owner (highest): - All admin permissions - Can transfer ownership - Can delete the organization - Can manage billing - Cannot be removed by others Admin: - Can manage members (invite, remove, change roles except owner) - Can manage all data (CRUD on all projects, settings) - Can view organization settings Member (lowest): - Can view and create data - Can edit/delete their own data - Cannot manage members or settings
Permission Helper
"Create a usePermissions hook that: 1. Reads the current user's role from OrganizationContext 2. Provides permission check functions: - canManageMembers() — owner, admin - canManageSettings() — owner, admin - canManageBilling() — owner only - canDeleteOrganization() — owner only - canEditProject(project) — owner, admin, or project creator - canDeleteProject(project) — owner, admin, or project creator 3. Provides a PermissionGate component:This renders children only if the user has permission. Otherwise renders nothing (or an optional fallback)."
Role-Based UI
"On the organization settings page: 1. Show tabs: General, Members, Billing 2. General tab: visible to admin+, editable by admin+ 3. Members tab: visible to all, invite/remove by admin+ 4. Billing tab: visible to owner only 5. 'Delete organization' button: visible to owner only, requires typing the organization name to confirm If a member navigates to a restricted tab via URL, show: 'You don't have permission to access this page. Contact your organization admin.'"
Step 5: Create the Invitation Flow
Sending Invitations
"On the Members tab, add an 'Invite Member' button that:
1. Opens a form: email address, role (admin or member)
2. On submit:
a. Check if email is already a member (show error if yes)
b. Check if there's a pending invitation (show error if yes)
c. Create an invitation record with a unique token
d. Send an invitation email via Supabase Edge Function:
'You've been invited to join [Org Name] on [App Name].
Click here to accept: [invitation_url]'
e. Show the invitation in the pending invitations list
The invitation URL format:
/invite/:token
Invitations expire after 7 days."
Accepting Invitations
"Create an /invite/:token page that:
1. Validates the token exists and is not expired
2. If the user is logged in:
a. Show: 'You've been invited to join [Org Name] as [role]'
b. 'Accept' button: creates membership, updates invitation
status to 'accepted', redirects to the organization
c. 'Decline' button: updates invitation status to 'declined',
redirects to home
3. If the user is NOT logged in:
a. Show the invitation details
b. 'Sign up to accept' button: redirects to signup with
return URL to the invitation page
c. 'Log in to accept' button: redirects to login with
return URL
Handle edge cases:
- Token not found: 'This invitation does not exist'
- Token expired: 'This invitation has expired. Ask the
organization admin to send a new one.'
- Already a member: 'You're already a member of this
organization.' with link to the org dashboard"
Managing Pending Invitations
"In the Members tab, show two sections: Active Members: - Name, email, role, joined date - For admin+: change role dropdown, remove button - Owner cannot be removed or have role changed Pending Invitations: - Email, invited role, invited by, sent date, expires date - 'Resend' button (resets expiration) - 'Cancel' button (deletes the invitation)"
Step 6: Build the Dashboard
Tenant-Scoped Data Loading
"Create a dashboard at /org/:slug/dashboard that shows: 1. Overview cards: - Total projects (count) - Active projects (count where status = 'active') - Team members (count from memberships) - Projects this week (count where created_at > 7 days ago) 2. Recent projects table: - Name, status, created by, created date - Click to navigate to /org/:slug/projects/:id - Pagination (10 per page) 3. Activity feed: - Recent changes across the organization - Show: who did what, when ALL queries must filter by the current organization_id from OrganizationContext. Use Supabase RLS as the security layer, but also include the organization_id filter in queries for performance (so the database uses the index)."
Data Creation with Automatic Org Scoping
"When creating a new project via the 'New Project' form: 1. The form does NOT show an organization field — organization_id is automatically set from OrganizationContext 2. created_by is automatically set from the current user 3. The user should never need to think about organization scoping — it happens transparently Implement this pattern as a reusable hook: useOrgQuery(tableName) — automatically adds organization_id filter to all SELECT queries useOrgInsert(tableName) — automatically adds organization_id to all INSERT operations"
Testing Multi-Tenancy
Manual Test Checklist
Test with 2 organizations and 2 users:
User A: owner of Org 1, member of Org 2
User B: admin of Org 2, no access to Org 1
[ ] User A sees Org 1 data on Org 1 dashboard
[ ] User A sees Org 2 data on Org 2 dashboard
[ ] User A does NOT see Org 2 data on Org 1 dashboard
[ ] User B cannot access Org 1 at all (redirect or 403)
[ ] User B can manage members in Org 2 (admin role)
[ ] User A can manage billing in Org 1 (owner role)
[ ] User B cannot manage billing in Org 2 (admin, not owner)
[ ] Creating a project in Org 1 is invisible in Org 2
[ ] Direct URL manipulation (/org/org2-slug/) is blocked for User B
[ ] Supabase direct query (via DevTools) returns only
authorized data (RLS enforced)
Security Verification
"The most important test: open browser DevTools, go to the Network tab, and manually modify a Supabase query to remove the organization_id filter. If RLS is configured correctly, the query should still return only the current organization's data. If it returns data from other organizations, RLS is misconfigured — stop and fix before proceeding."
Frequently Asked Questions
Can non-technical founders build this with Lovable?
The basic structure (tables, UI, invitation flow) is buildable with Lovable prompts. The RLS policies require understanding what they do — even if Lovable generates the SQL, you must verify the policies are correct. For production use, have a developer review the RLS configuration.
How does this scale to thousands of organizations?
Supabase RLS with proper indexes (organization_id indexed on every table) handles thousands of organizations efficiently. The database query planner uses the RLS policy to filter before returning data, not after.
What about organization-specific settings and branding?
Add a settings JSONB column to the organizations table. Store custom branding (logo URL, color scheme), feature flags, and configuration per organization.
How do I handle users who belong to many organizations?
The organization switcher handles this. For users with 10+ organizations, add a search/filter to the switcher dropdown. The URL-based routing (/org/:slug/) ensures the correct organization context is always explicit.
What about data export and deletion for GDPR?
Add an admin function to export all organization data as JSON/CSV. Add organization deletion that cascades to all data (memberships, projects, invitations). This satisfies the right to data portability and right to erasure.