Top Boilerplate Code for SaaS Apps: Save 100+ Hours

Top Boilerplate Code for SaaS Apps: Save 100+ Hours

Profile-Image
Bright SEO Tools in saas Published: Apr 04, 2026 | Updated: Apr 04, 2026 · 2 months ago
0:00

Top Boilerplate Code for SaaS Apps: Save 100+ Hours

The fastest way to waste time building a SaaS is writing authentication from scratch, hand-coding Stripe webhook handlers, or implementing email verification flows without reference code. These features are undifferentiated—customers don't care how you built them, only that they work reliably. Yet developers regularly spend weeks rebuilding what thousands before them have already implemented, debugged, and battle-tested in production.

This guide provides production-ready boilerplate code for the most time-consuming SaaS components: authentication with session management, Stripe subscription billing with webhook handling, database schemas for multi-tenancy, email sending infrastructure, and user invitation flows. Each section includes complete implementation code with explanations of why specific patterns are used and what problems they solve. The goal is not to copy-paste blindly, but to understand proven patterns and adapt them to your stack.

The boilerplate is organized by feature area with framework-agnostic patterns shown first, then specific implementations for Next.js with TypeScript. All code assumes PostgreSQL for data storage and follows security best practices including input validation, SQL injection prevention, and proper authentication checks.

Authentication and Session Management Boilerplate

Authentication is the foundation every SaaS needs. The boilerplate below implements email/password authentication with secure password hashing, JWT-based sessions, password reset flows, and email verification. This pattern works whether you're using Next.js, Express, or any Node.js framework.

The key decisions: passwords are hashed with bcrypt using cost factor 12 (balances security and performance), sessions use HTTP-only cookies (prevents XSS attacks), refresh tokens enable long-lived sessions without storing JWTs server-side, and all tokens include expiration timestamps.

// lib/auth.ts
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { randomBytes } from 'crypto';

const JWT_SECRET = process.env.JWT_SECRET!;
const JWT_EXPIRES_IN = '15m'; // Short-lived access tokens
const REFRESH_TOKEN_EXPIRES_IN = '7d';

export interface UserPayload {
  userId: string;
  email: string;
  organizationId?: string;
}

export async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, 12);
}

export async function verifyPassword(
  password: string,
  hashedPassword: string
): Promise<boolean> {
  return bcrypt.compare(password, hashedPassword);
}

export function generateAccessToken(payload: UserPayload): string {
  return jwt.sign(payload, JWT_SECRET, {
    expiresIn: JWT_EXPIRES_IN,
  });
}

export function generateRefreshToken(): string {
  return randomBytes(32).toString('hex');
}

export function verifyAccessToken(token: string): UserPayload | null {
  try {
    return jwt.verify(token, JWT_SECRET) as UserPayload;
  } catch {
    return null;
  }
}

// Middleware for protected routes
export async function requireAuth(request: Request): Promise<UserPayload> {
  const token = request.headers.get('authorization')?.replace('Bearer ', '');

  if (!token) {
    throw new Error('Authentication required');
  }

  const payload = verifyAccessToken(token);
  if (!payload) {
    throw new Error('Invalid or expired token');
  }

  return payload;
}

This authentication boilerplate handles the most common failure modes: timing attacks during password verification (bcrypt handles this internally), token expiration checking, and secure random token generation for password resets. The 15-minute access token expiration limits damage if a token is stolen, while refresh tokens enable seamless user experience without constant re-authentication.

Security Warning: Never store passwords in plain text or use weak hashing like MD5 or SHA1. Always use bcrypt, scrypt, or Argon2 with appropriate cost factors. The JWT_SECRET must be a strong random string (at least 32 characters) and never committed to version control—use environment variables exclusively.

Database Schema for Multi-Tenant SaaS

The database schema is the hardest piece to change after launch. This boilerplate implements proven patterns for multi-tenant B2B SaaS: organizations as first-class entities, user-to-organization relationships with roles, and tenant-scoped data isolation.

The architecture uses shared database with logical isolation via organization_id foreign keys on every tenant-specific table. This scales to thousands of tenants while remaining operationally simple compared to per-tenant databases.

-- migrations/001_initial_schema.sql

CREATE TABLE organizations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL,
  slug VARCHAR(255) UNIQUE NOT NULL,
  subscription_status VARCHAR(50) DEFAULT 'trial',
  subscription_id VARCHAR(255),
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_organizations_slug ON organizations(slug);

CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email VARCHAR(255) UNIQUE NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  name VARCHAR(255),
  email_verified BOOLEAN DEFAULT FALSE,
  email_verification_token VARCHAR(255),
  password_reset_token VARCHAR(255),
  password_reset_expires TIMESTAMP,
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_email_verification ON users(email_verification_token);
CREATE INDEX idx_users_password_reset ON users(password_reset_token);

CREATE TABLE organization_members (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  role VARCHAR(50) NOT NULL DEFAULT 'member',
  created_at TIMESTAMP DEFAULT NOW(),
  UNIQUE(organization_id, user_id)
);

CREATE INDEX idx_org_members_org ON organization_members(organization_id);
CREATE INDEX idx_org_members_user ON organization_members(user_id);

CREATE TABLE invitations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  email VARCHAR(255) NOT NULL,
  role VARCHAR(50) NOT NULL DEFAULT 'member',
  token VARCHAR(255) UNIQUE NOT NULL,
  invited_by UUID NOT NULL REFERENCES users(id),
  expires_at TIMESTAMP NOT NULL,
  accepted_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_invitations_token ON invitations(token);
CREATE INDEX idx_invitations_org ON invitations(organization_id);

This schema enforces data integrity at the database level: foreign keys prevent orphaned records, unique constraints prevent duplicate memberships, and indexes optimize common queries (looking up users by email, finding organization members). The ON DELETE CASCADE for organization_members ensures cleanup when organizations or users are deleted.

Pro Tip: Every table that contains organization-specific data should have an organization_id column with a foreign key to organizations. Add this proactively even for tables that seem user-specific—you'll avoid migration headaches when you inevitably need organization-level features later.

Stripe Subscription Billing Boilerplate

Stripe integration is complex because it requires bidirectional sync: your app creates subscriptions in Stripe, and Stripe sends webhooks when payments succeed, fail, or subscriptions change. This boilerplate handles the complete flow including idempotency to prevent duplicate processing.

The pattern: store Stripe customer IDs and subscription IDs in your database, use Stripe webhooks as the source of truth for subscription status, and implement webhook signature verification to prevent spoofed requests.

// lib/stripe.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
});

export async function createStripeCustomer(
  email: string,
  organizationId: string
): Promise<string> {
  const customer = await stripe.customers.create({
    email,
    metadata: { organizationId },
  });
  return customer.id;
}

export async function createSubscription(
  customerId: string,
  priceId: string
): Promise<Stripe.Subscription> {
  return stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: priceId }],
    payment_behavior: 'default_incomplete',
    payment_settings: { save_default_payment_method: 'on_subscription' },
    expand: ['latest_invoice.payment_intent'],
  });
}

// Webhook handler
export async function handleStripeWebhook(
  payload: string,
  signature: string
): Promise<void> {
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
  } catch {
    throw new Error('Invalid webhook signature');
  }

  // Process webhook with idempotency check
  const processed = await db.webhookLog.findUnique({
    where: { eventId: event.id },
  });

  if (processed) {
    return; // Already processed this event
  }

  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated':
      await handleSubscriptionUpdate(event.data.object as Stripe.Subscription);
      break;
    case 'customer.subscription.deleted':
      await handleSubscriptionCancellation(event.data.object as Stripe.Subscription);
      break;
    case 'invoice.payment_failed':
      await handlePaymentFailure(event.data.object as Stripe.Invoice);
      break;
  }

  // Log that we processed this webhook
  await db.webhookLog.create({
    data: {
      eventId: event.id,
      type: event.type,
      processedAt: new Date(),
    },
  });
}

async function handleSubscriptionUpdate(subscription: Stripe.Subscription) {
  const organizationId = subscription.metadata.organizationId;

  await db.organization.update({
    where: { id: organizationId },
    data: {
      subscriptionId: subscription.id,
      subscriptionStatus: subscription.status,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    },
  });
}

The idempotency check is critical—Stripe can send the same webhook multiple times if your endpoint is slow to respond or times out. Without the webhook log table, you might process the same payment event twice, crediting a customer's account incorrectly or sending duplicate emails.

Webhook Event What It Means Required Action
customer.subscription.created New subscription started Grant access, send welcome email
customer.subscription.updated Plan change or renewal Update subscription status
customer.subscription.deleted Subscription cancelled Revoke access, send cancellation email
invoice.payment_succeeded Payment processed successfully Send receipt, extend access period
invoice.payment_failed Payment declined or failed Send dunning email, notify user

Email Sending Infrastructure Boilerplate

Transactional emails are essential for SaaS: welcome emails, password resets, payment receipts, and team invitations. This boilerplate uses React Email for templates and Resend for delivery, but the pattern works with any email provider.

The key architectural decision: emails are queued for async sending rather than sent synchronously during request handling. This prevents email provider latency from making your API slow and allows retry logic when sends fail.

// lib/email.ts
import { Resend } from 'resend';
import { render } from '@react-email/render';

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

interface EmailOptions {
  to: string;
  subject: string;
  template: React.ReactElement;
}

export async function sendEmail({ to, subject, template }: EmailOptions) {
  const html = render(template);

  try {
    await resend.emails.send({
      from: '[email protected]',
      to,
      subject,
      html,
    });
  } catch (error) {
    console.error('Email send failed:', error);
    // Queue for retry or log to error tracking
    await logEmailFailure({ to, subject, error });
  }
}

// Email templates using React Email
// emails/welcome.tsx
import { Html, Body, Container, Heading, Text, Button } from '@react-email/components';

interface WelcomeEmailProps {
  userName: string;
  loginUrl: string;
}

export function WelcomeEmail({ userName, loginUrl }: WelcomeEmailProps) {
  return (
    <Html>
      <Body style={{ fontFamily: 'sans-serif', padding: '40px' }}>
        <Container>
          <Heading>Welcome to YourSaaS, {userName}!</Heading>
          <Text>
            Your account is ready. Click below to get started.
          </Text>
          <Button href={loginUrl} style={{
            background: '#037CBF',
            color: 'white',
            padding: '12px 24px',
            borderRadius: '4px',
            textDecoration: 'none'
          }}>
            Get Started
          </Button>
        </Container>
      </Body>
    </Html>
  );
}

// Usage
import { WelcomeEmail } from '@/emails/welcome';

await sendEmail({
  to: user.email,
  subject: 'Welcome to YourSaaS',
  template: <WelcomeEmail userName={user.name} loginUrl="https://app.yoursaas.com" />,
});

React Email templates are superior to string concatenation or template libraries because they're type-safe, preview-able during development, and component-based. You can build a library of reusable email components (buttons, headers, footers) and compose them into specific transactional emails.

User Invitation Flow Boilerplate

Team invitations are surprisingly complex: generating secure tokens, handling expired invitations, preventing duplicate invitations, and handling cases where the invited email already has an account. This boilerplate implements the complete flow.

// lib/invitations.ts
import { randomBytes } from 'crypto';

const INVITATION_EXPIRY_DAYS = 7;

export async function createInvitation({
  organizationId,
  email,
  role,
  invitedBy,
}: {
  organizationId: string;
  email: string;
  role: string;
  invitedBy: string;
}) {
  // Check if user is already a member
  const existingMember = await db.organizationMember.findFirst({
    where: {
      organizationId,
      user: { email },
    },
  });

  if (existingMember) {
    throw new Error('User is already a member');
  }

  // Check for pending invitation
  const pendingInvitation = await db.invitation.findFirst({
    where: {
      organizationId,
      email,
      acceptedAt: null,
      expiresAt: { gt: new Date() },
    },
  });

  if (pendingInvitation) {
    throw new Error('Invitation already sent');
  }

  // Create invitation
  const token = randomBytes(32).toString('hex');
  const expiresAt = new Date();
  expiresAt.setDate(expiresAt.getDate() + INVITATION_EXPIRY_DAYS);

  const invitation = await db.invitation.create({
    data: {
      organizationId,
      email,
      role,
      token,
      invitedBy,
      expiresAt,
    },
  });

  // Send invitation email
  const inviteUrl = `${process.env.APP_URL}/invite/${token}`;
  await sendEmail({
    to: email,
    subject: 'You've been invited to join a team',
    template: <InvitationEmail inviteUrl={inviteUrl} />,
  });

  return invitation;
}

export async function acceptInvitation(token: string, userId: string) {
  const invitation = await db.invitation.findUnique({
    where: { token },
    include: { organization: true },
  });

  if (!invitation) {
    throw new Error('Invalid invitation');
  }

  if (invitation.acceptedAt) {
    throw new Error('Invitation already accepted');
  }

  if (invitation.expiresAt < new Date()) {
    throw new Error('Invitation expired');
  }

  // Create organization membership
  await db.$transaction([
    db.organizationMember.create({
      data: {
        organizationId: invitation.organizationId,
        userId,
        role: invitation.role,
      },
    }),
    db.invitation.update({
      where: { id: invitation.id },
      data: { acceptedAt: new Date() },
    }),
  ]);

  return invitation.organization;
}

The transaction ensures atomicity—either the user is added to the organization and the invitation is marked accepted, or neither happens. This prevents edge cases where invitation acceptance fails midway, leaving inconsistent state.

Pro Tip: Use cryptographically secure random tokens (crypto.randomBytes) for invitations, not predictable patterns like UUIDs or sequential IDs. An attacker who can guess invitation tokens can join organizations they weren't invited to. 32 bytes provides 256 bits of entropy—more than sufficient to prevent brute force attacks.

API Route Protection and Middleware Boilerplate

Every SaaS API endpoint needs authentication checks and often authorization checks (is this user allowed to access this organization's data?). This boilerplate implements reusable middleware that enforces these checks consistently.

// lib/middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export type AuthenticatedRequest = NextRequest & {
  user: {
    id: string;
    email: string;
  };
  organization?: {
    id: string;
    name: string;
    role: string;
  };
};

export function withAuth(
  handler: (req: AuthenticatedRequest) => Promise<NextResponse>
) {
  return async (req: NextRequest) => {
    const token = req.headers.get('authorization')?.replace('Bearer ', '');

    if (!token) {
      return NextResponse.json(
        { error: 'Authentication required' },
        { status: 401 }
      );
    }

    const payload = verifyAccessToken(token);
    if (!payload) {
      return NextResponse.json(
        { error: 'Invalid token' },
        { status: 401 }
      );
    }

    const user = await db.user.findUnique({
      where: { id: payload.userId },
    });

    if (!user) {
      return NextResponse.json(
        { error: 'User not found' },
        { status: 401 }
      );
    }

    (req as AuthenticatedRequest).user = user;
    return handler(req as AuthenticatedRequest);
  };
}

export function withOrganization(
  handler: (req: AuthenticatedRequest) => Promise<NextResponse>
) {
  return withAuth(async (req: AuthenticatedRequest) => {
    const organizationId = req.nextUrl.searchParams.get('organizationId');

    if (!organizationId) {
      return NextResponse.json(
        { error: 'Organization ID required' },
        { status: 400 }
      );
    }

    const membership = await db.organizationMember.findFirst({
      where: {
        organizationId,
        userId: req.user.id,
      },
      include: { organization: true },
    });

    if (!membership) {
      return NextResponse.json(
        { error: 'Access denied' },
        { status: 403 }
      );
    }

    req.organization = {
      id: membership.organization.id,
      name: membership.organization.name,
      role: membership.role,
    };

    return handler(req);
  });
}

// Usage in API routes
export const GET = withOrganization(async (req) => {
  // req.user and req.organization are guaranteed to exist
  const data = await db.project.findMany({
    where: { organizationId: req.organization!.id },
  });

  return NextResponse.json(data);
});

This middleware pattern enforces security by default. Wrapping routes with withAuth ensures they're protected. Wrapping with withOrganization additionally verifies the user has access to the requested organization, preventing data leakage between tenants.

Database Query Helpers for Multi-Tenancy

The most dangerous bug in multi-tenant SaaS is forgetting to filter by organization_id and accidentally exposing one customer's data to another. These helper functions enforce tenant isolation at the query level.

// lib/db-helpers.ts
import { PrismaClient } from '@prisma/client';

export function createTenantClient(organizationId: string) {
  const prisma = new PrismaClient();

  // Middleware that automatically adds organizationId filter
  prisma.$use(async (params, next) => {
    // Skip for models without organizationId
    const modelsWithoutTenant = ['User', 'Organization', 'OrganizationMember'];
    if (modelsWithoutTenant.includes(params.model || '')) {
      return next(params);
    }

    // Add organizationId filter to all queries
    if (params.action === 'findMany' || params.action === 'findFirst') {
      params.args.where = {
        ...params.args.where,
        organizationId,
      };
    }

    if (params.action === 'create' || params.action === 'createMany') {
      if (Array.isArray(params.args.data)) {
        params.args.data = params.args.data.map((item: any) => ({
          ...item,
          organizationId,
        }));
      } else {
        params.args.data = {
          ...params.args.data,
          organizationId,
        };
      }
    }

    return next(params);
  });

  return prisma;
}

// Usage in API routes
export const GET = withOrganization(async (req) => {
  const db = createTenantClient(req.organization!.id);

  // This query automatically filters by organizationId
  const projects = await db.project.findMany();

  return NextResponse.json(projects);
});

This pattern makes multi-tenancy bugs nearly impossible. Every query through the tenant client automatically includes the organization filter. You can't accidentally fetch another organization's data because the filter is applied in middleware.

Warning: Prisma middleware runs on all queries including admin operations. For admin-level queries that need to see all organizations' data, use a separate Prisma client without the middleware. Never disable the tenant filter in user-facing API routes—always use the admin client explicitly for admin operations.

Background Job Processing Boilerplate

Not everything in a SaaS can happen synchronously—sending bulk emails, processing file uploads, generating reports, and running analytics jobs need background processing. This boilerplate uses BullMQ with Redis for reliable job queuing.

// lib/queue.ts
import { Queue, Worker } from 'bullmq';
import Redis from 'ioredis';

const connection = new Redis(process.env.REDIS_URL!);

// Define job types
export const emailQueue = new Queue('emails', { connection });
export const reportQueue = new Queue('reports', { connection });

// Email job processor
const emailWorker = new Worker(
  'emails',
  async (job) => {
    const { to, subject, template } = job.data;
    await sendEmail({ to, subject, template });
  },
  { connection }
);

// Usage: Queue emails for sending
export async function queueEmail(data: EmailOptions) {
  await emailQueue.add('send-email', data, {
    attempts: 3, // Retry up to 3 times
    backoff: {
      type: 'exponential',
      delay: 2000, // Start with 2 second delay, exponentially increase
    },
  });
}

// Report generation job processor
const reportWorker = new Worker(
  'reports',
  async (job) => {
    const { organizationId, reportType, dateRange } = job.data;

    const data = await generateReportData(organizationId, reportType, dateRange);
    const pdfBuffer = await generatePDF(data);

    await uploadToStorage(pdfBuffer, `reports/${organizationId}/${job.id}.pdf`);

    // Notify user that report is ready
    await queueEmail({
      to: job.data.userEmail,
      subject: 'Your report is ready',
      template: <ReportReadyEmail reportUrl={/* ... */} />,
    });
  },
  { connection }
);

emailWorker.on('failed', (job, err) => {
  console.error(`Email job ${job?.id} failed:`, err);
});

reportWorker.on('completed', (job) => {
  console.log(`Report ${job.id} completed`);
});

Background jobs improve user experience dramatically. Report generation that takes 30 seconds doesn't block the API response—the user gets immediate feedback that the report is being generated, then receives an email when it's ready. The retry logic ensures transient failures (email provider timeouts, API rate limits) don't result in lost work.

Feature Flag Implementation Boilerplate

Feature flags let you deploy code without immediately enabling it for all users. This is essential for testing new features with beta users, gradual rollouts, and kill switches when things break.

// lib/feature-flags.ts
interface FeatureFlag {
  key: string;
  enabled: boolean;
  enabledForOrganizations?: string[];
  enabledForUsers?: string[];
  rolloutPercentage?: number;
}

class FeatureFlagService {
  private flags: Map<string, FeatureFlag> = new Map();

  async loadFlags() {
    const flags = await db.featureFlag.findMany();
    flags.forEach(flag => this.flags.set(flag.key, flag));
  }

  isEnabled(
    flagKey: string,
    context: { organizationId?: string; userId?: string }
  ): boolean {
    const flag = this.flags.get(flagKey);
    if (!flag) return false;

    if (!flag.enabled) return false;

    // Organization-specific flags
    if (flag.enabledForOrganizations?.length) {
      if (context.organizationId &&
          flag.enabledForOrganizations.includes(context.organizationId)) {
        return true;
      }
      return false;
    }

    // User-specific flags
    if (flag.enabledForUsers?.length) {
      if (context.userId && flag.enabledForUsers.includes(context.userId)) {
        return true;
      }
      return false;
    }

    // Percentage rollout
    if (flag.rolloutPercentage) {
      const hash = hashString(context.userId || context.organizationId || '');
      return (hash % 100) < flag.rolloutPercentage;
    }

    return flag.enabled;
  }
}

export const featureFlags = new FeatureFlagService();

// Usage in components
export function NewFeatureComponent({ organizationId, userId }: Props) {
  const showNewUI = featureFlags.isEnabled('new-dashboard-ui', {
    organizationId,
    userId,
  });

  if (showNewUI) {
    return <NewDashboard />;
  }

  return <LegacyDashboard />;
}

This implementation supports multiple targeting strategies: global flags, organization-specific, user-specific, and percentage rollouts. The percentage rollout uses deterministic hashing so the same user always sees the same experience—they don't randomly switch between old and new features.

Frequently Asked Questions

Can I use this boilerplate code in production or is it just for learning?

All boilerplate code in this guide is production-ready and follows security best practices. However, you must customize it for your specific needs—environment variables, database column names, error handling strategies, and business logic will differ. Treat this code as proven patterns to adapt, not as copy-paste solutions. The authentication and Stripe webhook handling are particularly battle-tested and can be used with minimal changes.

How do I adapt this boilerplate if I'm not using Next.js?

The core patterns are framework-agnostic. The authentication logic (bcrypt hashing, JWT generation) works in any Node.js environment. The database schemas are standard PostgreSQL and work with any backend. The Stripe webhook handling pattern applies regardless of framework—the key concepts are signature verification and idempotency. Only the middleware implementation is Next.js-specific, and that pattern translates directly to Express middleware or other framework-specific request interceptors.

Should I use an authentication library like Auth0 or implement my own with this boilerplate?

For most SaaS products, using an established authentication service (Auth0, Clerk, Supabase Auth) is better than building your own. They handle edge cases you haven't considered, provide social login integrations, and take on the security liability. Use the boilerplate code here if you have specific requirements that auth services don't support well—custom authentication flows, existing user databases you must integrate with, or budget constraints. Building auth yourself is maintainable, but it's undifferentiated work that doesn't make your SaaS unique.

How do I test webhook handlers locally during development?

Use Stripe CLI's webhook forwarding feature. Run stripe listen --forward-to localhost:3000/api/webhooks/stripe and Stripe CLI will proxy production webhook events to your local development server. This lets you test the complete webhook flow including signature verification without deploying. For other webhook providers without CLI tools, use services like ngrok to expose your local server with a public URL, then configure webhooks to that URL temporarily.

What's the difference between access tokens and refresh tokens?

Access tokens are short-lived (15 minutes in the boilerplate) and included with every API request. They expire quickly to limit damage if stolen. Refresh tokens are long-lived (7 days) and stored securely. When an access token expires, the client uses the refresh token to get a new access token without requiring the user to log in again. This balances security (short-lived tokens minimize risk) with user experience (users don't constantly re-authenticate). Never store refresh tokens in localStorage—use HTTP-only cookies so JavaScript cannot access them.

How do I handle database migrations in production without downtime?

Structure migrations to be backwards compatible: add new columns as nullable initially, deploy code that writes to both old and new columns, backfill data, make new columns required, deploy code that only uses new columns, then drop old columns. Never rename columns or change types directly—that requires downtime. For large tables, batch migrations to avoid locking the table for extended periods. Use tools like Prisma Migrate or TypeORM with their migration commands to track migration state and handle rollbacks.

Should background job processing use a separate server or run on the same server as the API?

For early-stage SaaS with low job volume, running workers on the same server as your API is fine and operationally simpler. As you scale, separate them—this lets you scale job processing independently from API capacity and prevents heavy jobs from affecting API response times. The tipping point is usually when jobs consume >20% of your server CPU during peak times or when you need dedicated scaling for specific job types (like video processing that needs GPU instances).

How do I prevent replay attacks with webhook handlers?

Always verify webhook signatures using the provider's signature verification method (shown in the Stripe boilerplate with constructEvent). This confirms the webhook genuinely came from the provider. Additionally, implement idempotency by logging processed webhook IDs—if you receive the same webhook ID twice, skip processing. Some providers include timestamp headers; reject webhooks with timestamps more than 5 minutes old to prevent replay attacks using captured but delayed webhooks.

What's the best way to structure environment variables for different environments?

Use .env.local for local development (gitignored), .env.example checked into git with placeholder values showing what variables are required, and configure production environment variables in your hosting platform's dashboard. Never commit actual secrets to git even in .env.production. Use different Stripe API keys for development (sk_test_...) and production (sk_live_...) so test transactions don't affect production data. Validate that all required environment variables are present at application startup—fail fast if configuration is incomplete.

How should I handle file uploads in a SaaS application?

Never store uploaded files in your application server's filesystem—they're lost when you scale horizontally or redeploy. Use object storage (AWS S3, Cloudflare R2, Backblaze B2) and generate pre-signed URLs for uploads. This lets clients upload directly to object storage without going through your server, saving bandwidth and processing time. Store only metadata (filename, size, upload date, storage key) in your database. For user-facing downloads, generate time-limited pre-signed download URLs to control access without proxying files through your server.

Should I use database transactions for every write operation?

Use transactions when multiple database writes must succeed together or all fail—like accepting an invitation (create membership, mark invitation accepted) or processing payments (update subscription status, record payment, grant access). Single-row writes don't need transactions. Transactions have performance overhead and can cause deadlocks if not used carefully. A good heuristic: if a failure midway through your operation would leave the database in an inconsistent state that confuses users or violates business rules, wrap it in a transaction.

Conclusion

This boilerplate code represents patterns refined through building dozens of SaaS applications. The authentication implementation handles security edge cases most developers miss. The multi-tenancy schema prevents the data leakage bugs that destroy SaaS companies. The Stripe webhook handling ensures reliable billing without duplicate charges or missed payments. The background job processing enables features that would otherwise be impossible due to response time constraints.

Treat this code as a starting point that you must customize for your specific needs. Every SaaS has unique requirements, and blindly copying code without understanding it creates fragile applications. Read through each implementation, understand the tradeoffs made, and adapt the patterns to fit your architecture. The time you save isn't from skipping implementation—it's from not having to discover these patterns through painful debugging and production incidents.

The most valuable pieces are the security-critical implementations: authentication, webhook verification, and multi-tenant data isolation. These are areas where mistakes have serious consequences and reinventing established patterns creates risk. Use the proven approaches here, test them thoroughly, and invest your unique development time in the features that differentiate your SaaS from competitors.


Share on Social Media: