0306090120150180210240270300330km/h
0 km/h
ADIT JANGID

Architecture Breakdown of a Modern SaaS Template

A deep dive into the engineering choices, database schemas, and multi-tenant billing models of a production-ready SaaS template using Next.js, Prisma, Stripe, and Kinde.

Architecture Breakdown of a Modern SaaS Template

Introduction

Building a Software-as-a-Service (SaaS) application from scratch is an exercise in repetitive engineering. Every SaaS requires a similar baseline: multi-tenancy workspace isolation, secure authentication, role-based access control (RBAC), subscription billing, transactional emails, and file storage.

In this breakdown, we dissect the technical architecture of a modern, production-grade SaaS template built to sustain rapid development and scale to enterprise needs. We will focus on multi-tenant database designs, Stripe synchronization state machines, and API security.


High-Level Architecture Overview

A robust SaaS architecture must separate billing, authentication, and client state cleanly. Our stack consists of:

  • Framework: Next.js (App Router) for hybrid static/dynamic rendering.
  • Database ORM: Prisma interacting with a PostgreSQL database.
  • Authentication: Kinde (OIDC Provider) handling federated and passwordless login.
  • Payments & Billing: Stripe with webhook-driven state replication.
  • File Storage: Cloudflare R2 (S3-compatible) with pre-signed upload URLs.

Database Design: Multi-Tenancy Strategy

One of the first structural decisions is how to isolate customer data. The three classic patterns are:

  1. Database-per-tenant: High cost, complex schema migration, excellent isolation.
  2. Schema-per-tenant: Medium cost, easier migrations, good isolation.
  3. Shared database, Shared schema (Tenant ID column): Extremely cost-effective, simplest migrations, requires strict query filtering.

For our template, we chose the Tenant ID (Workspace) column pattern. To prevent developers from accidentally leaking data across workspaces, we enforce workspace filters at the database query level using Prisma middleware/extensions.

The Complete Prisma Schema

Here is the concrete schema definition representing users, workspaces, membership roles, and subscription status:

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

enum Role {
  OWNER
  ADMIN
  MEMBER
  VIEWER
}

enum SubscriptionStatus {
  ACTIVE
  PAST_DUE
  CANCELED
  UNPAID
  INCOMPLETE
}

model User {
  id            String       @id @default(uuid())
  email         String       @unique
  name          String?
  createdAt     DateTime     @default(now())
  updatedAt     DateTime     @updatedAt
  memberships   Membership[]
}

model Workspace {
  id           String        @id @default(uuid())
  slug         String        @unique
  name         String
  createdAt    DateTime      @default(now())
  updatedAt    DateTime      @updatedAt
  members      Membership[]
  subscription Subscription?
  auditLogs    AuditLog[]
}

model Membership {
  id          String    @id @default(uuid())
  role        Role      @default(MEMBER)
  userId      String
  workspaceId String
  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  workspace   Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)

  @@unique([userId, workspaceId])
  @@index([workspaceId])
}

model Subscription {
  id                   String             @id @default(uuid())
  workspaceId          String             @unique
  stripeCustomerId     String             @unique
  stripeSubscriptionId String?            @unique
  stripePriceId        String?
  status               SubscriptionStatus @default(INCOMPLETE)
  currentPeriodEnd     DateTime?
  workspace            Workspace          @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
}

model AuditLog {
  id          String    @id @default(uuid())
  workspaceId String
  userId      String
  action      String
  ipAddress   String?
  userAgent   String?
  createdAt   DateTime  @default(now())
  workspace   Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)

  @@index([workspaceId, createdAt])
}

Authentication & Workspace Middleware

Using Next.js Middleware, we inspect sessions and route requests to ensure the logged-in user actually has access to the workspace they are requesting via the URL path (e.g., /dashboard/[workspaceSlug]).

Here is our routing and workspace authorization check implementation:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getKindeServerSession } from '@kinde-oss/kinde-auth-nextjs/server';
import { prisma } from '@/lib/db';

export async function middleware(request: NextRequest) {
  const { isAuthenticated, getUser } = getKindeServerSession(request);
  
  if (!(await isAuthenticated())) {
    const loginUrl = new URL('/api/auth/login', request.url);
    loginUrl.searchParams.set('post_login_redirect_url', request.nextUrl.pathname);
    return NextResponse.redirect(loginUrl);
  }

  const user = await getUser();
  const pathParts = request.nextUrl.pathname.split('/');
  const workspaceSlug = pathParts[2]; // Path format: /dashboard/[workspaceSlug]/...

  if (workspaceSlug) {
    // Validate if the user is a member of the requested workspace
    const membership = await prisma.membership.findFirst({
      where: {
        user: { id: user.id },
        workspace: { slug: workspaceSlug },
      },
    });

    if (!membership) {
      // Forbidden: Redirect to account setup or selection page
      return NextResponse.redirect(new URL('/dashboard', request.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*'],
};

Stripe Webhook Handler State Machine

Managing subscription lifecycles is notoriously complex. You must handle payment failures, plan upgrades, cancellations, and grace periods asynchronously.

The Stripe Webhook acts as the single source of truth for subscription status. Below is our robust, transactional Express/Next.js App Router API webhook handler:

import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { prisma } from '@/lib/db';
import { SubscriptionStatus } from '@prisma/client';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
});

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: Request) {
  const body = await req.text();
  const signature = headers().get('stripe-signature') as string;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err: any) {
    return new NextResponse(`Webhook Error: ${err.message}`, { status: 400 });
  }

  const session = event.data.object as any;

  switch (event.type) {
    case 'checkout.session.completed': {
      // Retrieve customer and subscription details
      const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
      const workspaceId = session.metadata.workspaceId;

      await prisma.subscription.update({
        where: { workspaceId },
        data: {
          stripeSubscriptionId: subscription.id,
          stripeCustomerId: subscription.customer as string,
          stripePriceId: subscription.items.data[0].price.id,
          status: mapStripeStatus(subscription.status),
          currentPeriodEnd: new Date(subscription.current_period_end * 1000),
        },
      });
      break;
    }

    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription;
      await prisma.subscription.update({
        where: { stripeSubscriptionId: subscription.id },
        data: {
          stripePriceId: subscription.items.data[0].price.id,
          status: mapStripeStatus(subscription.status),
          currentPeriodEnd: new Date(subscription.current_period_end * 1000),
        },
      });
      break;
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;
      await prisma.subscription.update({
        where: { stripeSubscriptionId: subscription.id },
        data: {
          status: SubscriptionStatus.CANCELED,
        },
      });
      break;
    }
  }

  return new NextResponse('Webhook processed successfully', { status: 200 });
}

function mapStripeStatus(status: string): SubscriptionStatus {
  switch (status) {
    case 'active': return SubscriptionStatus.ACTIVE;
    case 'past_due': return SubscriptionStatus.PAST_DUE;
    case 'canceled': return SubscriptionStatus.CANCELED;
    case 'unpaid': return SubscriptionStatus.UNPAID;
    default: return SubscriptionStatus.INCOMPLETE;
  }
}

Conclusion & Deployment Checklist

Having a pre-architected SaaS template cuts development time from months to days. When building your own, ensure that:

  1. Workspace IDs are indexed everywhere in database models.
  2. Webhooks retry gracefully with idempotent key handling.
  3. Next.js middleware is kept lightweight to avoid blocking client latency.
saasarchitecturemulti-tenancystripeprisma

Related Posts

Interested in working together?

Let's translate complex software and automation problems into clean, high-performance systems.