onezlabs

0%

Full Stack

Building a Multi-Tenant SaaS with Next.js and Stripe

A complete technical guide to multi-tenant SaaS architecture — tenant isolation, Stripe billing, role-based access, and the deployment setup that keeps it all running.

June 28, 202614 min read
#Next.js#Stripe#SaaS#Multi-tenant#PostgreSQL#TypeScript
Building a Multi-Tenant SaaS with Next.js and Stripe

Multi-Tenancy Architecture Models

Multi-tenant SaaS architecture comes in three variants, each with different tradeoffs between isolation strength, cost, and operational complexity.

Shared database, shared schema (most common): All tenants share a single database and all tables have a tenant_id or organization_id column to separate their data. This is the most cost-efficient approach and what this guide covers. The isolation risk is that a bug in your query layer could return one tenant's data to another — mitigated with row-level security at the database level.

Shared database, separate schema: Each tenant gets their own PostgreSQL schema (essentially a namespace) within a shared database instance. Stronger isolation, more complex migrations, harder to operate at large scale.

Separate database per tenant: Maximum isolation, useful for regulated industries (healthcare, finance) where data residency requirements mandate physical separation. Operationally complex and expensive — not recommended unless isolation requirements specifically demand it.

For most SaaS products, shared database with row-level security is the right starting point. This is the model I use in SaaS MVP builds and what the rest of this guide covers.

Database Schema for Multi-Tenancy

The schema foundation needs to be right from day one. Adding organization context to tables later is painful — do it in the initial schema.

-- Core multi-tenant tables
CREATE TABLE organizations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL,
  slug VARCHAR(100) UNIQUE NOT NULL,  -- used for subdomain routing
  stripe_customer_id VARCHAR(255),
  stripe_subscription_id VARCHAR(255),
  subscription_status VARCHAR(50) DEFAULT 'trialing',
  subscription_plan VARCHAR(50) DEFAULT 'free',
  trial_ends_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE organization_members (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  role VARCHAR(50) NOT NULL DEFAULT 'member',  -- owner | admin | member
  invited_email VARCHAR(255),
  accepted_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(organization_id, user_id)
);

-- Example: a resource belonging to a tenant
CREATE TABLE projects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  name VARCHAR(255) NOT NULL,
  description TEXT,
  created_by UUID REFERENCES auth.users(id),
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Row Level Security: users can only see their organization's data
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY "org_members_can_view_projects" ON projects
  FOR SELECT USING (
    organization_id IN (
      SELECT organization_id FROM organization_members
      WHERE user_id = auth.uid()
    )
  );

CREATE POLICY "org_admins_can_manage_projects" ON projects
  FOR ALL USING (
    organization_id IN (
      SELECT organization_id FROM organization_members
      WHERE user_id = auth.uid()
      AND role IN ('owner', 'admin')
    )
  );

Tenant Isolation in Next.js

Every request in a multi-tenant Next.js application needs to resolve which tenant is being served. The two common approaches: subdomain routing (tenant.yoursaas.com) and path-based routing (yoursaas.com/org/tenant). I use subdomain routing for the cleaner UX.

// middleware.ts — resolves tenant from subdomain
import { NextRequest, NextResponse } from "next/server";
import { createMiddlewareClient } from "@supabase/auth-helpers-nextjs";

export async function middleware(req: NextRequest) {
  const hostname = req.headers.get("host") || "";
  const currentHost = hostname.replace(".yoursaas.com", "").replace("www.", "");

  // Exclude root domain, www, and special subdomains
  const isRootDomain = hostname === "yoursaas.com" || hostname === "www.yoursaas.com";
  const isSpecialSubdomain = ["app", "api", "docs", "admin"].includes(currentHost);

  if (isRootDomain || isSpecialSubdomain) {
    return NextResponse.next();
  }

  // Set tenant context in request headers for downstream use
  const res = NextResponse.next();
  res.headers.set("x-tenant-slug", currentHost);

  // Verify tenant exists and user has access
  const supabase = createMiddlewareClient({ req, res });
  const { data: { session } } = await supabase.auth.getSession();

  if (!session) {
    return NextResponse.redirect(new URL("/login", req.url));
  }

  const { data: org } = await supabase
    .from("organizations")
    .select("id, subscription_status")
    .eq("slug", currentHost)
    .single();

  if (!org) {
    return NextResponse.redirect(new URL("/not-found", req.url));
  }

  // Check if user is a member of this organization
  const { data: membership } = await supabase
    .from("organization_members")
    .select("role")
    .eq("organization_id", org.id)
    .eq("user_id", session.user.id)
    .single();

  if (!membership) {
    return NextResponse.redirect(new URL("/unauthorized", req.url));
  }

  res.headers.set("x-tenant-id", org.id);
  res.headers.set("x-tenant-role", membership.role);

  return res;
}

Stripe Billing Integration

Multi-tenant SaaS billing requires linking Stripe customers to organizations, not to individual users. Each organization has one Stripe customer, and subscription status is stored on the organization record.

// lib/stripe.ts — Stripe setup and helpers
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-11-20",
  typescript: true,
});

export async function createOrGetStripeCustomer(
  orgId: string,
  orgName: string,
  adminEmail: string
): Promise<string> {
  // Check if org already has a Stripe customer
  const { data: org } = await supabase
    .from("organizations")
    .select("stripe_customer_id")
    .eq("id", orgId)
    .single();

  if (org?.stripe_customer_id) {
    return org.stripe_customer_id;
  }

  // Create new Stripe customer
  const customer = await stripe.customers.create({
    name: orgName,
    email: adminEmail,
    metadata: { organization_id: orgId },
  });

  // Store customer ID on organization
  await supabase
    .from("organizations")
    .update({ stripe_customer_id: customer.id })
    .eq("id", orgId);

  return customer.id;
}

// Checkout session for subscription upgrade
export async function createCheckoutSession(
  orgId: string,
  priceId: string,
  returnUrl: string
): Promise<string> {
  const { data: org } = await supabase
    .from("organizations")
    .select("stripe_customer_id, slug")
    .eq("id", orgId)
    .single();

  const session = await stripe.checkout.sessions.create({
    customer: org!.stripe_customer_id!,
    mode: "subscription",
    payment_method_types: ["card"],
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${returnUrl}?success=true&session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${returnUrl}?canceled=true`,
    subscription_data: {
      metadata: { organization_id: orgId },
      trial_period_days: 14,
    },
    metadata: { organization_id: orgId },
  });

  return session.url!;
}

Role-Based Access Control

Multi-tenant SaaS needs RBAC at two levels: organization-level roles (owner, admin, member) that control what a user can do within their organization, and subscription-level gates that control which features are available based on the organization's plan.

// lib/permissions.ts
type OrgRole = "owner" | "admin" | "member";
type Feature = "advanced_analytics" | "api_access" | "custom_domain" | "unlimited_projects";
type Plan = "free" | "starter" | "pro" | "enterprise";

const PLAN_FEATURES: Record<Plan, Feature[]> = {
  free: [],
  starter: ["advanced_analytics"],
  pro: ["advanced_analytics", "api_access", "unlimited_projects"],
  enterprise: ["advanced_analytics", "api_access", "unlimited_projects", "custom_domain"],
};

const ROLE_PERMISSIONS = {
  owner: ["manage_billing", "manage_members", "delete_organization", "manage_projects", "view_analytics"],
  admin: ["manage_members", "manage_projects", "view_analytics"],
  member: ["manage_projects", "view_analytics"],
} as const;

export function canAccess(role: OrgRole, permission: string): boolean {
  return ROLE_PERMISSIONS[role].includes(permission as never);
}

export function hasFeature(plan: Plan, feature: Feature): boolean {
  return PLAN_FEATURES[plan].includes(feature);
}

// React hook for permission checking
export function usePermissions(orgRole: OrgRole, orgPlan: Plan) {
  return {
    can: (permission: string) => canAccess(orgRole, permission),
    hasFeature: (feature: Feature) => hasFeature(orgPlan, feature),
    isOwner: orgRole === "owner",
    isAdmin: orgRole === "owner" || orgRole === "admin",
  };
}

Production Deployment Architecture

Subdomain-based multi-tenant SaaS requires wildcard DNS and wildcard SSL configuration at the infrastructure level. Here is the setup I use:

DNS: Wildcard CNAME record: *.yoursaas.com → yoursaas.com. This routes all subdomain traffic to your application server without needing individual DNS records per tenant.

Vercel deployment: Add both yoursaas.com and *.yoursaas.com as domains in your Vercel project settings. Vercel automatically provisions wildcard SSL certificates via Let's Encrypt. Update your Next.js config:

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          { key: "X-Frame-Options", value: "DENY" },
          { key: "X-Content-Type-Options", value: "nosniff" },
          { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
        ],
      },
    ];
  },
};

Database: Use Supabase (PostgreSQL + Auth + Row Level Security built in) or Neon for serverless PostgreSQL. Both work excellently with Vercel Edge functions. Enable connection pooling (PgBouncer in Supabase) to handle concurrent requests efficiently.

This architecture scales from your first customer to thousands without infrastructure changes. The Vercel + Supabase combination has no scaling step to worry about — both scale automatically based on usage. For bespoke multi-tenant SaaS builds, see my SaaS MVP service and check the project portfolio for delivered examples.

Frequently Asked Questions

What is the difference between multi-tenant and single-tenant SaaS?

Single-tenant SaaS runs a separate instance (database, server, application) per customer. Multi-tenant SaaS runs one shared instance that serves all customers, with data isolation enforced in the application layer. Multi-tenant is the standard for modern SaaS because it is dramatically more cost-efficient to operate — you maintain one codebase and one infrastructure rather than N copies.

How do you prevent one tenant's data from leaking to another?

Row-level security (RLS) at the database level is the most robust approach for PostgreSQL — Supabase enables this by default. The application layer enforces tenant context on every query through an organizationId filter. Middleware validates the current user's tenant membership before any data operation. Defense in depth: both application-level and database-level enforcement.

Should I build multi-tenancy from day one or add it later?

Build the data model for multi-tenancy from day one, even if your first version only has one tenant (yourself or the first customer). Adding an organizationId column to every table after the fact, when you have production data, is expensive and risky. The code changes for multi-tenancy are minor if the schema is right; they are painful if the schema was designed for single-tenant from the start.

Share:TwitterLinkedIn

Get the newsletter

Practical articles on AI development, full stack engineering, and WordPress — delivered whenI publish, not on a schedule. No spam, ever.

JO

Johnbert Oñez

AI Solutions Engineer & Full Stack Developer

Johnbert builds AI systems, web applications, and WordPress solutions for clients worldwide. Based in Davao City, Philippines. 6+ years, 50+ projects.

More Like This

Enjoyed the article? Let's build something together.

From AI chatbots to SaaS products — I turn ideas into working software. Based in Davao City, available worldwide.