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.

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:
- Database-per-tenant: High cost, complex schema migration, excellent isolation.
- Schema-per-tenant: Medium cost, easier migrations, good isolation.
- 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:
- Workspace IDs are indexed everywhere in database models.
- Webhooks retry gracefully with idempotent key handling.
- Next.js middleware is kept lightweight to avoid blocking client latency.