Template
4 hours
intermediate

SaaS Application Starter

Complete spec and architecture for building a production SaaS application with auth, billing, and multi-tenancy.

What you'll build

A fully functional SaaS foundation ready for your specific features.

Prerequisites

  • Node.js 20+ installed
  • PostgreSQL or Supabase account
  • Stripe account for billing

Overview

This template provides everything you need to launch a SaaS product:

  • Authentication - Email/password, OAuth, magic links
  • Multi-tenancy - Organizations with team management
  • Billing - Stripe subscriptions with usage-based pricing
  • Dashboard - Admin panel with analytics
  • API - Rate-limited REST API with keys

Choose Your Path

Before implementing, consider your priorities:

Option A: Build from Scratch (This Template)

Follow this template to implement your own backend. This approach is best when:

  • Learning backend architecture is part of your goal
  • You need complete customization
  • You want to own and maintain all infrastructure

Time estimate: 4+ hours for foundation, weeks for production-ready

Option B: Use a Backend Platform

If your goal is shipping your product's unique features, platforms like StackForDevs provide auth, billing, notifications, and multi-tenancy as ready-to-use APIs:

  • Focus on your frontend and business logic
  • Production-ready in hours instead of weeks
  • Migrate to self-hosted later if needed

Time estimate: Hours to integrate, then focus on your differentiating features

Note

Neither approach is wrong—choose based on whether backend infrastructure is your learning goal or a solved problem. This template teaches the patterns; platforms let you skip the implementation.


The rest of this template covers Option A: building from scratch.

Project Structure

your-saas/
├── prisma/
│   └── schema.prisma          # Database schema
├── src/
│   ├── app/
│   │   ├── (auth)/            # Auth pages (login, signup)
│   │   ├── (dashboard)/       # Protected dashboard
│   │   ├── api/               # API routes
│   │   └── layout.tsx
│   ├── components/
│   │   ├── auth/              # Auth components
│   │   ├── billing/           # Billing components
│   │   ├── dashboard/         # Dashboard components
│   │   └── ui/                # Shared UI components
│   ├── lib/
│   │   ├── auth/              # Auth utilities
│   │   ├── billing/           # Stripe integration
│   │   └── db/                # Database client
│   └── services/
│       ├── authService.ts
│       ├── billingService.ts
│       └── tenantService.ts
├── .env.example
└── package.json

Step 1: Initialize Project

Give this to Claude Code:

Create a new Next.js 14 project with:
- TypeScript strict mode
- Tailwind CSS
- App Router
- src directory

Initialize with:
npx create-next-app@latest my-saas --typescript --tailwind --eslint --app --src-dir

Then install dependencies:
npm install @prisma/client next-auth @auth/prisma-adapter stripe zod react-hook-form @tanstack/react-query
npm install -D prisma @types/node

Step 2: Database Schema

Create prisma/schema.prisma:

prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// Authentication
model User {
  id            String    @id @default(cuid())
  email         String    @unique
  name          String?
  emailVerified DateTime?
  image         String?
  password      String?   // For email/password auth
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  accounts      Account[]
  sessions      Session[]
  memberships   Membership[]
}

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

// Multi-tenancy
model Organization {
  id          String   @id @default(cuid())
  name        String
  slug        String   @unique
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  memberships  Membership[]
  subscription Subscription?
  apiKeys      ApiKey[]
  usageRecords UsageRecord[]
}

model Membership {
  id             String   @id @default(cuid())
  role           Role     @default(MEMBER)
  userId         String
  organizationId String
  createdAt      DateTime @default(now())

  user         User         @relation(fields: [userId], references: [id], onDelete: Cascade)
  organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)

  @@unique([userId, organizationId])
}

enum Role {
  OWNER
  ADMIN
  MEMBER
}

// Billing
model Subscription {
  id                   String   @id @default(cuid())
  organizationId       String   @unique
  stripeCustomerId     String   @unique
  stripeSubscriptionId String?  @unique
  stripePriceId        String?
  status               String   @default("inactive")
  currentPeriodEnd     DateTime?
  createdAt            DateTime @default(now())
  updatedAt            DateTime @updatedAt

  organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
}

model UsageRecord {
  id             String   @id @default(cuid())
  organizationId String
  metric         String   // e.g., "api_calls", "storage_mb"
  quantity       Int
  timestamp      DateTime @default(now())

  organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)

  @@index([organizationId, metric, timestamp])
}

// API Keys
model ApiKey {
  id             String   @id @default(cuid())
  name           String
  key            String   @unique
  organizationId String
  lastUsedAt     DateTime?
  createdAt      DateTime @default(now())
  expiresAt      DateTime?

  organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)

  @@index([key])
}

Step 3: Authentication Service

Create src/services/authService.ts:

typescript
import { prisma } from '@/lib/db';
import { hash, compare } from 'bcryptjs';
import { SignJWT, jwtVerify } from 'jose';

const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET);

export const authService = {
  async register(email: string, password: string, name: string) {
    const existing = await prisma.user.findUnique({ where: { email } });
    if (existing) {
      throw new Error('Email already registered');
    }

    const hashedPassword = await hash(password, 12);
    const user = await prisma.user.create({
      data: { email, password: hashedPassword, name },
    });

    // Create default organization
    const org = await prisma.organization.create({
      data: {
        name: `${name}'s Workspace`,
        slug: email.split('@')[0] + '-' + Date.now(),
        memberships: {
          create: { userId: user.id, role: 'OWNER' },
        },
      },
    });

    return { user, organization: org };
  },

  async login(email: string, password: string) {
    const user = await prisma.user.findUnique({
      where: { email },
      include: { memberships: { include: { organization: true } } },
    });

    if (!user || !user.password) {
      throw new Error('Invalid credentials');
    }

    const valid = await compare(password, user.password);
    if (!valid) {
      throw new Error('Invalid credentials');
    }

    const token = await this.createToken(user.id);
    return { user, token };
  },

  async createToken(userId: string) {
    return new SignJWT({ userId })
      .setProtectedHeader({ alg: 'HS256' })
      .setExpirationTime('7d')
      .sign(JWT_SECRET);
  },

  async verifyToken(token: string) {
    try {
      const { payload } = await jwtVerify(token, JWT_SECRET);
      return payload as { userId: string };
    } catch {
      return null;
    }
  },
};

Step 4: Billing Service

Create src/services/billingService.ts:

typescript
import Stripe from 'stripe';
import { prisma } from '@/lib/db';

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

export const billingService = {
  async createCustomer(organizationId: string, email: string) {
    const customer = await stripe.customers.create({
      email,
      metadata: { organizationId },
    });

    await prisma.subscription.create({
      data: {
        organizationId,
        stripeCustomerId: customer.id,
      },
    });

    return customer;
  },

  async createCheckoutSession(organizationId: string, priceId: string) {
    const subscription = await prisma.subscription.findUnique({
      where: { organizationId },
    });

    if (!subscription) {
      throw new Error('No subscription record found');
    }

    const session = await stripe.checkout.sessions.create({
      customer: subscription.stripeCustomerId,
      mode: 'subscription',
      line_items: [{ price: priceId, quantity: 1 }],
      success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard/billing?success=true`,
      cancel_url: `${process.env.NEXT_PUBLIC_URL}/dashboard/billing?canceled=true`,
      metadata: { organizationId },
    });

    return session;
  },

  async handleWebhook(event: Stripe.Event) {
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.Checkout.Session;
        await prisma.subscription.update({
          where: { stripeCustomerId: session.customer as string },
          data: {
            stripeSubscriptionId: session.subscription as string,
            status: 'active',
          },
        });
        break;
      }
      case 'customer.subscription.updated':
      case 'customer.subscription.deleted': {
        const subscription = event.data.object as Stripe.Subscription;
        await prisma.subscription.update({
          where: { stripeSubscriptionId: subscription.id },
          data: {
            status: subscription.status,
            currentPeriodEnd: new Date(subscription.current_period_end * 1000),
          },
        });
        break;
      }
    }
  },

  async recordUsage(organizationId: string, metric: string, quantity: number) {
    await prisma.usageRecord.create({
      data: { organizationId, metric, quantity },
    });
  },
};

Step 5: API Middleware

Create src/lib/auth/middleware.ts:

typescript
import { NextRequest, NextResponse } from 'next/server';
import { authService } from '@/services/authService';
import { prisma } from '@/lib/db';

export interface AuthenticatedRequest extends NextRequest {
  user: { id: string; email: string };
  organization: { id: string; name: string };
}

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

    let userId: string | null = null;
    let organizationId: string | null = null;

    // Check JWT token
    if (token) {
      const payload = await authService.verifyToken(token);
      if (payload) {
        userId = payload.userId;
      }
    }

    // Check API key
    if (apiKey && !userId) {
      const key = await prisma.apiKey.findUnique({
        where: { key: apiKey },
        include: { organization: true },
      });

      if (key && (!key.expiresAt || key.expiresAt > new Date())) {
        organizationId = key.organizationId;
        // Update last used
        await prisma.apiKey.update({
          where: { id: key.id },
          data: { lastUsedAt: new Date() },
        });
      }
    }

    if (!userId && !organizationId) {
      return NextResponse.json(
        { error: 'Unauthorized' },
        { status: 401 }
      );
    }

    // Get user and organization
    let user, organization;

    if (userId) {
      user = await prisma.user.findUnique({
        where: { id: userId },
        include: {
          memberships: {
            include: { organization: true },
            take: 1,
          },
        },
      });

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

      organization = user.memberships[0]?.organization;
    } else if (organizationId) {
      organization = await prisma.organization.findUnique({
        where: { id: organizationId },
      });
    }

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

    const authReq = req as AuthenticatedRequest;
    authReq.user = user ? { id: user.id, email: user.email } : { id: '', email: '' };
    authReq.organization = { id: organization.id, name: organization.name };

    return handler(authReq);
  };
}

Step 6: Example API Route

Create src/app/api/organizations/route.ts:

typescript
import { NextResponse } from 'next/server';
import { withAuth, AuthenticatedRequest } from '@/lib/auth/middleware';
import { prisma } from '@/lib/db';
import { billingService } from '@/services/billingService';

export const GET = withAuth(async (req: AuthenticatedRequest) => {
  const org = await prisma.organization.findUnique({
    where: { id: req.organization.id },
    include: {
      memberships: { include: { user: true } },
      subscription: true,
    },
  });

  // Record API usage
  await billingService.recordUsage(req.organization.id, 'api_calls', 1);

  return NextResponse.json(org);
});

Environment Variables

Create .env.example:

bash
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

# Authentication
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
NEXTAUTH_URL="http://localhost:3000"

# Stripe
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."

# App
NEXT_PUBLIC_URL="http://localhost:3000"

Prompt for Claude Code

Use this prompt to have Claude Code implement the full template:

Implement a SaaS starter based on the template at /templates/saas-starter.

The project should have:
1. Next.js 14 with App Router
2. Prisma with the schema provided
3. Authentication with JWT (email/password for now)
4. Stripe billing integration
5. Multi-tenant architecture with organizations
6. API key authentication for programmatic access

Start by:
1. Creating the project structure
2. Setting up Prisma schema and running migrations
3. Implementing the auth service
4. Creating the billing service
5. Adding the auth middleware
6. Creating example API routes

Follow the patterns from the template exactly.

Next Steps

Once the foundation is in place:

  1. Add OAuth providers - Google, GitHub, etc.
  2. Build the dashboard UI - Use shadcn/ui components
  3. Add team invitations - Email-based invite flow
  4. Implement webhooks - For real-time integrations
  5. Add rate limiting - Protect API endpoints

Common Customizations

Frequently Asked Questions

Add to your NextAuth configuration:

typescript
providers: [
  GoogleProvider({
    clientId: process.env.GOOGLE_CLIENT_ID!,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  }),
],

Create price IDs in Stripe Dashboard, then update your pricing page to reference them. The subscription model will track which plan they're on.

Extend the Role enum in Prisma schema, then add permission checks in your middleware based on membership.role.