All guides
Engineering16 min read

Building a SaaS Product from Scratch: Architecture Guide

The decisions you make in the first weeks of a SaaS product define the constraints you work within for years. This guide covers the architecture choices that matter most.

NC

Nextcraft Agency

The Decisions That Compound

Architecture isn't about picking the right tools — it's about making decisions that remain correct as the product grows. The patterns that work for 10 users may not work for 10,000. But over-engineering for 10,000 users when you have 10 is expensive and often wrong about which things will scale.

This guide is organized by the order you'll face these decisions when building a SaaS product.


Phase 1: The Core Data Model

Your data model is the most important architectural decision you'll make. Everything else — APIs, UI, analytics — is built on top of it. Getting the data model wrong is expensive to fix.

Multi-Tenancy First

If you're building B2B SaaS, design for multi-tenancy from day one — even if you have one customer. Adding multi-tenancy later requires rewriting your entire data access layer.

The three approaches to multi-tenancy:

Row-level isolation: All tenants share tables. A workspace_id or organization_id column on every tenant-owned table, with every query filtered by it.

code
// Every query must include the tenant filter
const projects = await db.project.findMany({
  where: { workspaceId: currentWorkspace.id },
});

This is the approach used by most SaaS companies. It's simple, cost-efficient, and works at scale with proper indexing. The risk: missing a workspaceId filter exposes one tenant's data to another — catastrophic if it happens.

Schema isolation: Each tenant gets their own database schema (PostgreSQL schemas, not "database" schemas). All tables exist in each schema; the application switches schemas based on the current tenant.

More isolation than row-level, but significantly more complex to manage. Worth it for compliance-heavy industries (healthcare, financial services) that require proof of data isolation.

Database isolation: Each tenant gets their own database. Maximum isolation, maximum cost, maximum operational complexity. Reserved for enterprise accounts with contractual isolation requirements.

Start with row-level isolation. Add schema isolation for enterprise accounts if contractual requirements demand it.

The Workspace/Organization Model

For B2B SaaS, the standard entity hierarchy:

code
User (authentication identity)
  └── Membership (role within an organization)
        └── Organization (tenant)
              └── [Product entities: projects, resources, etc.]
code
// Prisma schema
model User {
  id           String       @id @default(cuid())
  email        String       @unique
  memberships  Membership[]
}

model Membership {
  id             String       @id @default(cuid())
  role           Role         @default(MEMBER)
  userId         String
  organizationId String
  user           User         @relation(fields: [userId], references: [id])
  organization   Organization @relation(fields: [organizationId], references: [id])
  
  @@unique([userId, organizationId])
}

model Organization {
  id          String       @id @default(cuid())
  name        String
  slug        String       @unique
  plan        Plan         @default(FREE)
  memberships Membership[]
  projects    Project[]
}

A user can belong to multiple organizations (common for agency clients, contractors). The Membership model carries the role.


Phase 2: Authentication

Don't build authentication. Use a service.

The options and when to use them:

  • Clerk: Full-featured auth with UI components. Best for speed to market.
  • Auth.js (NextAuth v5): Self-hosted, highly configurable. Best for full control.
  • Supabase Auth: Best if Supabase is your database (RLS integration).

After authentication, you need authorization — who can do what within an organization.

Role-Based Access Control

code
enum Role { OWNER, ADMIN, MEMBER, VIEWER }

// Permission check utility
const permissions = {
  [Role.VIEWER]: ['project:read', 'resource:read'],
  [Role.MEMBER]: ['project:read', 'project:create', 'resource:read', 'resource:create'],
  [Role.ADMIN]: ['project:read', 'project:create', 'project:delete', 'member:invite'],
  [Role.OWNER]: ['*'], // All permissions
} as const;

function can(role: Role, action: string): boolean {
  const allowed = permissions[role];
  return allowed.includes('*') || allowed.includes(action);
}

Centralize all permission checks. Scattered if (user.role === 'admin') checks throughout the codebase inevitably create gaps.


Phase 3: The API Layer

For a Next.js SaaS:

  • Server Actions: For mutations initiated from within the Next.js app
  • Route Handlers: For webhooks, external clients, programmatic access
  • tRPC (optional): For typed RPC when you have a separate React Native app

Don't build REST unless you need it for external consumers. Server Actions + tRPC cover 90% of use cases with less code and better type safety.


Phase 4: Billing and Subscriptions

Billing is non-trivial. Don't build it. Use Stripe.

The Subscription Model

code
model Organization {
  // ... other fields
  stripeCustomerId  String? @unique
  plan              Plan    @default(FREE)
  planExpiresAt     DateTime?
}
code
// lib/billing.ts
export async function createCheckoutSession(orgId: string, priceId: string) {
  const org = await db.organization.findUnique({ where: { id: orgId } });
  
  // Create or retrieve Stripe customer
  let customerId = org?.stripeCustomerId;
  if (!customerId) {
    const customer = await stripe.customers.create({
      metadata: { organizationId: orgId },
    });
    customerId = customer.id;
    await db.organization.update({
      where: { id: orgId },
      data: { stripeCustomerId: customerId },
    });
  }
  
  return stripe.checkout.sessions.create({
    customer: customerId,
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
  });
}

Webhook Handling

Stripe sends webhooks for subscription events. Handle them reliably:

code
// app/api/stripe/webhook/route.ts
export async function POST(request: Request) {
  const body = await request.text();
  const sig = request.headers.get('stripe-signature')!;
  
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }
  
  switch (event.type) {
    case 'customer.subscription.updated':
    case 'customer.subscription.created': {
      const subscription = event.data.object as Stripe.Subscription;
      await syncSubscription(subscription);
      break;
    }
    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;
      await downgradeToFree(subscription.customer as string);
      break;
    }
  }
  
  return new Response(null, { status: 200 });
}

Use idempotency: webhook handlers can be called multiple times for the same event. Design them to be safe to run twice.


Phase 5: Email

You need three types of email:

Transactional: Triggered by user actions — password reset, invite, payment receipt. Use Resend or Postmark. Never use a marketing platform for transactional email.

Onboarding sequences: Automated sequences for new users. Your ESP (email service provider) handles this — Loops, Customer.io, or Brevo.

Product notifications: "You've been mentioned in a comment," "Your export is ready." Use your transactional email provider.

code
// lib/email.ts
import { Resend } from 'resend';
import { InviteEmailTemplate } from '@/emails/invite';

const resend = new Resend(process.env.RESEND_API_KEY);

export async function sendInviteEmail({
  to,
  inviterName,
  orgName,
  inviteUrl,
}: InviteEmailParams) {
  return resend.emails.send({
    from: 'Nextcraft <hello@nextcraft.agency>',
    to,
    subject: `${inviterName} invited you to ${orgName}`,
    react: <InviteEmailTemplate inviterName={inviterName} orgName={orgName} inviteUrl={inviteUrl} />,
  });
}

Use React Email for email templates — it handles the cross-client compatibility nightmares of HTML email.


Phase 6: Background Jobs

Some operations shouldn't block the HTTP request:

  • Sending emails
  • Processing uploads
  • Generating reports
  • Syncing with third-party services

Options for background jobs in Next.js:

Vercel Cron Jobs: For scheduled tasks (daily reports, cache warming). Simple, serverless, no infrastructure.

Trigger.dev: For complex event-driven workflows with retries, logging, and monitoring. The best option for most SaaS teams.

Inngest: Similar to Trigger.dev, slightly different DX. Also excellent.

code
// trigger.dev example
import { task } from '@trigger.dev/sdk/v3';

export const processUpload = task({
  id: 'process-upload',
  run: async (payload: { fileId: string; userId: string }) => {
    const file = await storage.download(payload.fileId);
    const processed = await processFile(file);
    await storage.upload(processed, `processed/${payload.fileId}`);
    await notifyUser(payload.userId, 'Your file is ready');
  },
  retryConfig: { maxAttempts: 3 },
});

Phase 7: Observability

You need three things to understand what your production system is doing:

Error tracking: Sentry captures exceptions, gives you stack traces, and groups similar errors. Non-negotiable for production.

Application performance monitoring (APM): Understanding slow database queries, slow API routes, and bottlenecks. Vercel's built-in metrics cover the basics; Datadog or New Relic for deeper analysis.

Logging: Structured logs that you can search. Use pino for structured logging; ship logs to Axiom or Datadog.

code
// lib/logger.ts
import pino from 'pino';

export const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  formatters: {
    level: (label) => ({ level: label }),
  },
});

// Usage
logger.info({ userId, action: 'project.created', projectId }, 'Project created');
logger.error({ error, userId, projectId }, 'Failed to create project');

The Architecture Checklist

Before you launch:

  • Multi-tenancy enforced at the data layer, not just the application layer
  • Role-based access control centralized and tested
  • All mutations require authentication and authorization
  • Stripe webhook handler is idempotent
  • Background jobs have retry logic and failure notifications
  • Errors reported to Sentry (or equivalent)
  • Database indexes on all foreign keys and frequently filtered columns
  • Environment variables validated at startup
  • Rate limiting on all public API endpoints
  • CSRF protection on all Server Actions (Next.js handles this)

The goal is a system where each layer has a clear responsibility, failures are contained, and adding new features doesn't require understanding the entire codebase. That's the architecture that scales.

Stay Informed.

Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.

Insights once a month. No spam. Unsubscribe anytime.