Back to Blog
Blog Post

Lessons from Multi-Tenant SaaS Architecture with NestJS

8 min read
NestJS
PostgreSQL
Multi-Tenant
SaaS Architecture

What I learned building CampHost.io — a multi-tenant campground management platform — about tenant isolation, shared databases, and scaling a NestJS backend.

CampHost.io campground management platform

CampHost.io is a campground management platform I built from scratch. Campground owners sign up, configure their properties, and manage reservations through a shared platform. Each campground is a tenant — they see only their own data, but they’re all running on the same infrastructure.

Multi-tenancy sounds simple until you actually build it. Here’s what I learned.

Choosing a Tenancy Model

There are three common approaches to multi-tenancy:

  1. Separate databases — Each tenant gets their own database. Maximum isolation, maximum ops overhead.
  2. Shared database, separate schemas — One database, but each tenant gets their own schema (PostgreSQL makes this easy). Good isolation, moderate complexity.
  3. Shared database, shared schema — One database, one schema, tenant ID column on every table. Simplest to manage, requires discipline.

I went with option 3 — shared everything with a tenant_id column. Here’s why:

  • Fewer moving parts. One database connection pool, one migration pipeline, one backup strategy.
  • Easier analytics. Cross-tenant queries (for platform admin dashboards) are trivial.
  • Cost-effective. At the early stage, provisioning separate databases per campground would’ve been wasteful.

The tradeoff is that tenant isolation is enforced at the application layer, not the infrastructure layer. One missed WHERE tenant_id = ? and you’ve got a data leak. That’s where NestJS’s architecture really helps.

Tenant-Scoped Everything

NestJS’s dependency injection system lets you build middleware and guards that extract the tenant context from every request and make it available throughout the request lifecycle:

@Injectable()
export class TenantMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const tenantId = this.extractTenantId(req);
    if (!tenantId) throw new UnauthorizedException('Tenant not found');
    req['tenantId'] = tenantId;
    next();
  }
}

Every repository method then includes the tenant filter automatically. I built a base repository class that enforces this:

export abstract class TenantScopedRepository<T> {
  async findAll(tenantId: string): Promise<T[]> {
    return this.repo.find({ where: { tenantId } });
  }

  async findOne(tenantId: string, id: string): Promise<T> {
    const entity = await this.repo.findOne({ where: { id, tenantId } });
    if (!entity) throw new NotFoundException();
    return entity;
  }
}

This pattern makes it structurally difficult to accidentally query across tenants. The tenant ID isn’t optional — it’s baked into every data access method.

Row-Level Security as a Safety Net

Application-level filtering is necessary but not sufficient. I added PostgreSQL Row-Level Security (RLS) as a defense-in-depth layer:

ALTER TABLE reservations ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON reservations
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

Before each request, the application sets the app.current_tenant session variable. Even if application code has a bug that skips the tenant filter, the database itself will only return rows belonging to the current tenant.

This is the kind of belt-and-suspenders approach that lets you sleep at night when other people’s business data is on the line.

The Reservation Engine

The core of CampHost is the reservation system. Campsite availability is a surprisingly complex problem:

  • Overlapping date ranges — You can’t double-book a site. This requires careful range queries and locking.
  • Site types and capacity — Some campgrounds have RV sites, tent sites, cabins — each with different rules.
  • Seasonal pricing — Rates change based on time of year, day of week, and holidays.
  • Minimum stay requirements — Some sites require 2-night minimums on weekends.

I modeled availability as a series of date range queries with exclusion logic, using PostgreSQL’s daterange type and the && (overlaps) operator:

SELECT * FROM sites
WHERE id NOT IN (
  SELECT site_id FROM reservations
  WHERE daterange(check_in, check_out) && daterange($1, $2)
    AND status != 'cancelled'
)
AND tenant_id = $3;

Scaling Considerations

At the current scale (dozens of campgrounds, thousands of reservations), the shared-schema approach works perfectly. If CampHost grows to thousands of tenants, I’d consider:

  • Read replicas for analytics queries that don’t need to touch the primary
  • Table partitioning by tenant_id for the largest tables
  • Connection pooling with PgBouncer to handle connection limits

The migration to separate schemas or databases would be painful but possible — the tenant-scoped repository pattern means the data access layer is already isolated.

Key Takeaways

Start with shared-schema. The ops simplicity is worth the application-layer discipline, especially early on. You can always shard later.

Enforce tenancy at multiple layers. Application middleware catches most issues. RLS catches the rest. Neither alone is sufficient.

NestJS is excellent for this. The module system, dependency injection, and middleware pipeline make it natural to build cross-cutting concerns like tenant isolation into the framework rather than sprinkling checks throughout business logic.

Think about the query patterns early. Multi-tenant data access has different performance characteristics than single-tenant. Index on (tenant_id, ...) for every table, and be intentional about which queries need to span tenants.

Hire me today
KP
Kolbey's Assistant Available for work
Hey — I'm Kolbey's portfolio assistant. Ask me about his skills, projects, experience, or how to get in touch.