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:
prismagenerator 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:
typescriptimport { 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:
typescriptimport 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:
typescriptimport { 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:
typescriptimport { 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:
- Add OAuth providers - Google, GitHub, etc.
- Build the dashboard UI - Use shadcn/ui components
- Add team invitations - Email-based invite flow
- Implement webhooks - For real-time integrations
- Add rate limiting - Protect API endpoints
Common Customizations
Frequently Asked Questions
Add to your NextAuth configuration:
typescriptproviders: [ 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.