How We Built a Multi-Tenant SaaS with Next.js 16, Prisma 7, and Auth.js
We recently shipped Frostbyte Pro — an inventory management system for New Zealand businesses. Here's a technical breakdown of the architecture decisions we made and what we learned building a multi-tenant SaaS from scratch.
The Stack
- Next.js 16 (App Router) + React 19 + TypeScript
- PostgreSQL + Prisma 7 ORM
- Auth.js v5 (NextAuth) with Credentials provider
- Stripe for subscription billing
- Vercel for hosting
- shadcn/ui + Tailwind CSS v4 for the UI
Multi-Tenancy: Row-Level Isolation
We chose row-level isolation over schema-per-tenant. Every table that contains business data has an orgId column:
model Product {
id String @id @default(cuid())
orgId String
name String
sku String
// ...
org Organization @relation(fields: [orgId], references: [id])
@@unique([orgId, sku])
@@map("products")
}
The key to making this work safely is a single function that every query flows through:
// server/queries/helpers.ts
export async function getAuthenticatedContext() {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
const membership = await prisma.orgMember.findFirst({
where: { userId: session.user.id, isActive: true },
});
if (!membership) throw new Error("No active membership");
return {
userId: session.user.id,
orgId: membership.orgId,
role: membership.role,
};
}
Every server action and query starts with const { orgId } = await getAuthenticatedContext(). This makes it impossible to accidentally query another tenant's data.
RBAC: Keep It Simple
We use five roles: Owner > Admin > Manager > Staff > Viewer. Rather than complex permission trees, we use a simple numeric hierarchy:
const ROLE_HIERARCHY = {
VIEWER: 1,
STAFF: 2,
MANAGER: 3,
ADMIN: 4,
OWNER: 5,
};
export function hasPermission(userRole: Role, required: Role): boolean {
return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[required];
}
This covers 95% of use cases. A Manager can do everything a Staff member can, plus more. Simple to reason about, simple to implement.
Server Actions for Everything
We use Next.js Server Actions for all mutations. No API routes for internal operations. The pattern is consistent:
"use server";
export async function createProduct(data: ProductFormData) {
const { orgId, role } = await getAuthenticatedContext();
if (!hasPermission(role, "MANAGER")) {
throw new Error("Insufficient permissions");
}
// Validate with Zod
const parsed = createProductSchema.safeParse(data);
if (!parsed.success) {
return { error: parsed.error.issues[0].message };
}
// Create with orgId automatically scoped
const product = await prisma.product.create({
data: { ...parsed.data, orgId },
});
revalidatePath("/products");
return { success: true, id: product.id };
}
Server Components fetch data. Server Actions mutate it. No client-side API calls for business logic.
Stock Operations: Transactions Are Non-Negotiable
Inventory operations must be atomic. When you receive a purchase order, you need to:
- Update the PO status
- Create stock entries for each line item
- Update product quantities
- Log an audit trail entry
If step 3 fails, you can't have step 1 already committed. Everything goes through prisma.$transaction():
await prisma.$transaction(async (tx) => {
await tx.purchaseOrder.update({ ... });
for (const line of lines) {
await tx.stockEntry.create({ ... });
await tx.product.update({
where: { id: line.productId },
data: { totalQty: { increment: line.quantity } },
});
}
await tx.auditLog.create({ ... });
});
Trial Licensing + Stripe
New users get a 14-day free trial with no credit card. The license check happens in the Auth.js authorize() callback — before a session is even created:
authorize: async (credentials) => {
// ... verify password ...
const membership = await prisma.orgMember.findFirst({
where: { userId: user.id },
include: { org: { include: { license: true } } },
});
const license = membership?.org?.license;
if (!license || !license.isActive) return null;
if (license.expiresAt && license.expiresAt < new Date()) return null;
return user;
}
When the trial expires, users can't log in. They're redirected to a Stripe Checkout page. A webhook activates their license on successful payment.
What We'd Do Differently
Start with a monorepo. As the app grew, having a single
src/directory got unwieldy. A turborepo with separate packages for the marketing site, app, and shared code would have been cleaner.Use Prisma's connection pooling earlier. We hit serverless cold start issues before adopting Prisma Accelerate.
Plan the URL structure upfront. Changing routes after launch is painful with Next.js App Router — every file rename cascades through imports, middleware matchers, and redirect rules.
Try It Out
If you're interested in seeing the end result, Frostbyte Pro offers a 14-day free trial. It's a real production system handling inventory for NZ businesses — not a side project.
Got questions about the architecture? Drop a comment below.
Tags: #nextjs #prisma #saas #typescript
Platform: Dev.to
Top comments (0)