Multi-Tenant Architecture in Nuxt: Patterns for SaaS Products
Multi-tenancy means your product serves multiple customers from the same application, with each customer's data isolated from all others. It's the standard model for SaaS — and the architecture decisions you make early become difficult to change later.
Here's how to design multi-tenancy correctly in a Nuxt application.
The two approaches to data isolation
Shared database, tenant-scoped data:
All tenants share the same tables. Every row has a tenant_id (or workspace_id, organisation_id) column. Access control ensures users only see rows belonging to their tenant.
This is the right model for most SaaS products. It's simpler to operate, scales well, and allows efficient use of database resources.
Separate database per tenant: Each tenant gets their own database. Complete data isolation, but significantly more operational complexity. Required for compliance-sensitive scenarios (healthcare, finance) where data isolation must be physical, not logical.
For most startups: shared database with row-level security.
The tenant model
The core data structure:
-- The tenant (workspace, organization, account)
create table workspaces (
id uuid primary key default gen_random_uuid(),
slug text unique not null, -- For URL-based tenant routing
name text not null,
plan text not null default 'free',
created_at timestamptz default now()
);
-- Users belong to workspaces through this junction table
create table workspace_members (
workspace_id uuid references workspaces(id) on delete cascade,
user_id uuid references auth.users(id) on delete cascade,
role text not null default 'member', -- 'owner', 'admin', 'member'
joined_at timestamptz default now(),
primary key (workspace_id, user_id)
);
-- All tenant-specific data has a workspace_id
create table projects (
id uuid primary key default gen_random_uuid(),
workspace_id uuid references workspaces(id) on delete cascade not null,
name text not null,
created_by uuid references auth.users(id),
created_at timestamptz default now()
);
Row-level security with Supabase
Supabase's row-level security (RLS) policies enforce tenant isolation at the database layer:
-- Enable RLS on all tenant-scoped tables
alter table projects enable row level security;
-- Users can only see projects in their workspaces
create policy "Users can see their workspace projects"
on projects for select
using (
workspace_id in (
select workspace_id
from workspace_members
where user_id = auth.uid()
)
);
-- Users can only create projects in workspaces they belong to
create policy "Users can create projects in their workspaces"
on projects for insert
with check (
workspace_id in (
select workspace_id
from workspace_members
where user_id = auth.uid()
)
);
With these policies in place, a database query will never return data from another tenant — even if application code doesn't filter by workspace. This is defence in depth.
URL-based tenant routing
The two common approaches for routing:
Subdomain: company.yourapp.comPath: yourapp.com/w/company/...
Subdomains feel more "product-like" but require wildcard SSL and more complex DNS configuration. Path-based routing is easier to implement and works with standard SSL certificates.
For most early-stage products, path-based routing is the right call:
// pages/w/[workspace]/index.vue
const route = useRoute()
const workspaceSlug = computed(() => route.params.workspace as string)
const { data: workspace } = await useFetch(
() => `/api/workspaces/${workspaceSlug.value}`
)
// server/api/workspaces/[slug].get.ts
export default defineEventHandler(async (event) => {
const user = event.context.user
const slug = getRouterParam(event, 'slug')
const { data: workspace } = await supabase
.from('workspaces')
.select('*, workspace_members!inner(role)')
.eq('slug', slug)
.eq('workspace_members.user_id', user.id)
.single()
if (!workspace) throw createError({ statusCode: 404, message: 'Workspace not found' })
return workspace
})
Workspace context in your app
The workspace context needs to be available throughout the app. Use a composable:
// composables/useWorkspace.ts
export function useWorkspace() {
const route = useRoute()
const slug = computed(() => route.params.workspace as string)
const { data: workspace, pending } = useFetch(
() => `/api/workspaces/${slug.value}`,
{ watch: [slug] }
)
const isOwner = computed(() =>
workspace.value?.workspace_members[0]?.role === 'owner'
)
const isAdmin = computed(() =>
['owner', 'admin'].includes(workspace.value?.workspace_members[0]?.role ?? '')
)
return { workspace, pending, isOwner, isAdmin }
}
Invitation system
Multi-tenant products need a way for workspace owners to invite others:
// server/api/invitations.post.ts
export default defineEventHandler(async (event) => {
const { email, workspaceId, role } = await readBody(event)
const user = event.context.user
// Check the requester has permission to invite
const membership = await getWorkspaceMember(workspaceId, user.id)
if (!['owner', 'admin'].includes(membership.role)) {
throw createError({ statusCode: 403 })
}
// Create invitation token
const token = randomBytes(32).toString('hex')
await db.invitations.create({
workspaceId,
email,
role,
token,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
})
// Send invitation email
await sendInvitationEmail(email, user.name, workspace.name, token)
return { sent: true }
})
Plan-based feature gating
Multi-tenant SaaS products usually have plans. Gating features by plan:
// composables/usePlanFeatures.ts
export function usePlanFeatures() {
const { workspace } = useWorkspace()
const features = computed(() => ({
advancedAnalytics: ['pro', 'enterprise'].includes(workspace.value?.plan ?? ''),
customDomain: workspace.value?.plan === 'enterprise',
apiAccess: workspace.value?.plan !== 'free',
teamSize: workspace.value?.plan === 'enterprise' ? Infinity : 5
}))
return { features }
}
Gate in the UI:
<template>
<div v-if="features.apiAccess">
<!-- API settings -->
</div>
<UpgradePrompt v-else feature="API Access" />
</template>
Gate server-side too:
// In your API route
const workspace = await getWorkspace(workspaceId)
if (!['pro', 'enterprise'].includes(workspace.plan)) {
throw createError({ statusCode: 403, message: 'This feature requires a Pro plan.' })
}
Getting the model right early
Multi-tenancy is hard to retrofit. If you build a product assuming single tenancy and then add multi-tenancy, you're typically looking at a significant database migration and refactor.
If there's any chance your product will ever serve multiple organisations or teams, design for multi-tenancy from the start. The overhead is low when done early; it's expensive when done later.
Building a multi-tenant SaaS? Let's get the architecture right →