DEV Community

pipipi-dev
pipipi-dev

Posted on

Building Multi-Tenant SaaS as a Solo Developer

This article is part of the Solo SaaS Development Advent Calendar 2025.

In the previous article, I covered GA4 and Microsoft Clarity setup. In this article, I'll share how I built a multi-tenant SaaS architecture.

:::message
The content in this article reflects the design I adopted for my personal project. Rather than best practices, please read this as a record of trial and error from a solo developer.
:::

Why Multi-Tenant?

When building a SaaS, you need to separate data by user.

For team or organization-based services, the following requirements emerge:

  • Organization A's data must not be visible to Organization B
  • Even within the same organization, access rights may differ by team
  • Administrators and regular members have different operation scopes

Multi-tenant architecture realizes these requirements.

Why I Considered Multi-Tenant

For many solo development projects, multi-tenant architecture is probably unnecessary. For consumer-facing services, filtering by user ID is sufficient.

In my case, I was aiming to serve both individual users and enterprise customers, so I designed with multi-tenancy in mind from the start. Adding it later could require restructuring the entire data model.

Tenant Structure Design

In my project, I adopted the following hierarchical structure:

Tenant (Organization)
├── Team A
│   ├── Member 1 (Owner)
│   └── Member 2 (Regular)
└── Team B
    ├── Member 1 (Leader)
    └── Member 3 (View Only)
Enter fullscreen mode Exit fullscreen mode

Tenant Table Design

This table manages tenant (organization) information.

// Example: Tenant table
const tenants = pgTable('tenants', {
  tenant_id: text('tenant_id').primaryKey(),
  name: text('name').notNull(),
  slug: text('slug').unique().notNull(),
  owner_id: text('owner_id').notNull(),
  plan: text('plan').default('free'),
  status: text('status').default('active'),
  settings: jsonb('settings'),
  created_at: timestamp('created_at').defaultNow(),
});
Enter fullscreen mode Exit fullscreen mode

The slug is an identifier used in URLs. settings stores plan-specific limits in JSON format.

Membership Design

This manages the relationship between users and tenants.

// Example: Tenant members table
const tenantMembers = pgTable('tenants_members', {
  tenant_id: text('tenant_id').notNull(),
  user_id: text('user_id').notNull(),
  role: text('role').notNull(), // 'owner' | 'admin' | 'member' | 'viewer'
  status: text('status').default('active'),
  joined_at: timestamp('joined_at').defaultNow(),
}, (table) => ({
  pk: primaryKey({ columns: [table.tenant_id, table.user_id] }),
}));
Enter fullscreen mode Exit fullscreen mode

By using a composite primary key (tenant_id + user_id), I enabled users to belong to multiple tenants.

Role Definition

For example, the following roles can be considered:

Role Permissions
owner Full permissions, can delete tenant
admin Member management, settings changes
member Create and edit content
viewer View only

Row-Level Security (RLS)

The most important aspect of multi-tenancy is data isolation. If controlled only through application code, bugs could expose one tenant's data to another.

I adopted PostgreSQL's Row-Level Security (RLS), which enables access control at the database level.

RLS Policy Implementation

-- Enable RLS
ALTER TABLE app_content.labels ENABLE ROW LEVEL SECURITY;

-- SELECT: Only retrieve data from same tenant
CREATE POLICY labels_select_policy ON app_content.labels
  FOR SELECT
  USING (tenant_id = current_setting('app.current_tenant_id', true));

-- INSERT: Only create in same tenant, admin or above
CREATE POLICY labels_insert_policy ON app_content.labels
  FOR INSERT
  WITH CHECK (
    tenant_id = current_setting('app.current_tenant_id', true)
    AND current_setting('app.current_user_role', true) IN ('OWNER', 'ADMIN')
  );
Enter fullscreen mode Exit fullscreen mode

current_setting('app.current_tenant_id', true) retrieves the tenant ID set in the session.

Setting Session Context

I set the current tenant ID and role in the session during API requests.

// Setting in API middleware
async function setTenantContext(tenantId: string, role: string) {
  await db.execute(sql`
    SELECT set_config('app.current_tenant_id', ${tenantId}, true);
    SELECT set_config('app.current_user_role', ${role}, true);
  `);
}
Enter fullscreen mode Exit fullscreen mode

After this, subsequent queries are automatically filtered by RLS policies.

Access Control Implementation

Membership Verification

At API endpoints, I first verify whether the user is a member of the tenant.

// Verify tenant access
async function checkTenantAccess(userId: string, tenantId: string) {
  const membership = await db
    .select({ role: tenantMembers.role })
    .from(tenantMembers)
    .where(and(
      eq(tenantMembers.tenant_id, tenantId),
      eq(tenantMembers.user_id, userId),
      eq(tenantMembers.status, 'active')
    ));

  if (membership.length === 0) {
    throw new Error('Access denied');
  }

  return membership[0].role;
}
Enter fullscreen mode Exit fullscreen mode

Role-Based Permission Checks

I verify the required role for each operation.

// Example: Team creation
app.post('/api/tenants/:tenantId/teams', async (c) => {
  const user = c.get('user');
  const { tenantId } = c.req.param();

  // Verify membership
  const role = await checkTenantAccess(user.id, tenantId);

  // Only owner/admin can create
  if (!['owner', 'admin'].includes(role)) {
    return c.json({ error: 'No permission to create team' }, 403);
  }

  // Team creation logic...
});
Enter fullscreen mode Exit fullscreen mode

Data Isolation with Composite Keys

I use composite primary keys including tenant_id in all content tables.

// Example: Contents table
const contents = pgTable('contents', {
  tenant_id: text('tenant_id').notNull(),
  content_id: text('content_id').notNull(),
  title: text('title').notNull(),
  // ... other columns
}, (table) => ({
  pk: primaryKey({ columns: [table.tenant_id, table.content_id] }),
}));
Enter fullscreen mode Exit fullscreen mode

Master data can be managed as shared data across all tenants using a reserved tenant_id like 'SYSTEM'.

Implementation Tips

Explicit Filtering in Queries

In addition to RLS, I explicitly filter by tenant_id in application code as well.

// Add explicit filter
const userContents = await db
  .select()
  .from(contents)
  .where(and(
    eq(contents.tenant_id, tenantId),  // Explicit filter
    eq(contents.created_by, userId)
  ));
Enter fullscreen mode Exit fullscreen mode

Even with RLS, this makes intent clear during code reviews.

Tenant Switching Consideration

I also implemented switching functionality for users who belong to multiple tenants.

// Get list of tenants user belongs to
async function getUserTenants(userId: string) {
  return await db
    .select({
      tenant: tenants,
      role: tenantMembers.role,
    })
    .from(tenants)
    .innerJoin(tenantMembers, eq(tenants.tenant_id, tenantMembers.tenant_id))
    .where(and(
      eq(tenantMembers.user_id, userId),
      eq(tenantMembers.status, 'active')
    ));
}
Enter fullscreen mode Exit fullscreen mode

The current tenant is managed in the session and can be switched through the UI.

Infrastructure Options

In December 2025, Vercel announced "Vercel for Platforms." It provides features like automatic routing for wildcard domains (*.yourapp.com) and SSL certificate management for custom domains.

https://vercel.com/changelog/introducing-vercel-for-platforms

Combined with the data isolation design introduced in this article, you can build more sophisticated multi-tenant SaaS applications.

Summary

I've shared how I built a multi-tenant SaaS architecture.

Point Description
Tenant Structure Organization → Team → Member hierarchy
RLS Database-level isolation with PostgreSQL
Roles owner / admin / member / viewer, etc.
Composite Keys Design with tenant_id in primary keys
Double Check Verify in both RLS and application code

Whether you need to go this far in solo development depends on your product's nature. However, if you're aiming for enterprise SaaS, considering this from the start makes future expansion easier.


Other Articles in This Series

  • 12/21: Visualizing User Behavior: Setting Up GA4 and Microsoft Clarity
  • 12/23: How Claude Code Changed My Solo Development: AI Pair Programming in Practice

Top comments (0)