DEV Community

FrostByte Software NZ
FrostByte Software NZ

Posted on

How We Built a Multi-Tenant SaaS with Next.js 16, Prisma 7, and Auth.js

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")
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Update the PO status
  2. Create stock entries for each line item
  3. Update product quantities
  4. 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({ ... });
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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

  1. 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.

  2. Use Prisma's connection pooling earlier. We hit serverless cold start issues before adopting Prisma Accelerate.

  3. 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)