SaaS Scaffolder
Tier: POWERFUL Category: Engineering / Full-Stack Maintainer: Claude Skills Team
Overview
Generate a complete, production-ready SaaS application boilerplate including authentication (NextAuth, Clerk, or Supabase Auth), database schemas with multi-tenancy, billing integration (Stripe or Lemon Squeezy), API routes with validation, dashboard UI with shadcn/ui, and deployment configuration. Produces a working application from a product specification in under 30 minutes.
Keywords
SaaS, boilerplate, scaffolding, Next.js, authentication, Stripe, billing, multi-tenancy, subscription, starter template, NextAuth, Drizzle ORM, shadcn/ui
Input Specification
Product: [name] Description: [1-3 sentences] Auth: nextauth | clerk | supabase Database: neondb | supabase | planetscale | turso Payments: stripe | lemonsqueezy | none Multi-tenancy: workspace | organization | none Features: [comma-separated list]
Generated File Tree
my-saas/ ├── app/ │ ├── (auth)/ │ │ ├── login/page.tsx │ │ ├── register/page.tsx │ │ ├── forgot-password/page.tsx │ │ └── layout.tsx │ ├── (dashboard)/ │ │ ├── dashboard/page.tsx │ │ ├── settings/ │ │ │ ├── page.tsx # Profile settings │ │ │ ├── billing/page.tsx # Subscription management │ │ │ └── team/page.tsx # Team/workspace settings │ │ └── layout.tsx # Dashboard shell (sidebar + header) │ ├── (marketing)/ │ │ ├── page.tsx # Landing page │ │ ├── pricing/page.tsx # Pricing tiers │ │ └── layout.tsx │ ├── api/ │ │ ├── auth/[...nextauth]/route.ts │ │ ├── webhooks/stripe/route.ts │ │ ├── billing/ │ │ │ ├── checkout/route.ts │ │ │ └── portal/route.ts │ │ └── health/route.ts │ ├── layout.tsx # Root layout │ └── not-found.tsx ├── components/ │ ├── ui/ # shadcn/ui components │ ├── auth/ │ │ ├── login-form.tsx │ │ └── register-form.tsx │ ├── dashboard/ │ │ ├── sidebar.tsx │ │ ├── header.tsx │ │ └── stats-card.tsx │ ├── marketing/ │ │ ├── hero.tsx │ │ ├── features.tsx │ │ ├── pricing-card.tsx │ │ └── footer.tsx │ └── billing/ │ ├── plan-card.tsx │ └── usage-meter.tsx ├── lib/ │ ├── auth.ts # Auth configuration │ ├── db.ts # Database client singleton │ ├── stripe.ts # Stripe client │ ├── validations.ts # Zod schemas │ └── utils.ts # Shared utilities ├── db/ │ ├── schema.ts # Drizzle schema │ ├── migrations/ # Generated migrations │ └── seed.ts # Development seed data ├── hooks/ │ ├── use-subscription.ts │ └── use-current-user.ts ├── types/ │ └── index.ts # Shared TypeScript types ├── middleware.ts # Auth + rate limiting ├── .env.example ├── drizzle.config.ts ├── tailwind.config.ts └── next.config.ts
Database Schema (Multi-Tenant)
// db/schema.ts import { pgTable, text, timestamp, integer, boolean, uniqueIndex, index } from 'drizzle-orm/pg-core' import { createId } from '@paralleldrive/cuid2'
// ──── WORKSPACES (Tenancy boundary) ──── export const workspaces = pgTable('workspaces', { id: text('id').primaryKey().$defaultFn(createId), name: text('name').notNull(), slug: text('slug').notNull(), plan: text('plan').notNull().default('free'), // free | pro | enterprise stripeCustomerId: text('stripe_customer_id').unique(), stripeSubscriptionId: text('stripe_subscription_id'), stripePriceId: text('stripe_price_id'), stripeCurrentPeriodEnd: timestamp('stripe_current_period_end'), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }, (t) => [ uniqueIndex('workspaces_slug_idx').on(t.slug), ])
// ──── USERS ──── export const users = pgTable('users', { id: text('id').primaryKey().$defaultFn(createId), email: text('email').notNull().unique(), name: text('name'), avatarUrl: text('avatar_url'), emailVerified: timestamp('email_verified', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), })
// ──── WORKSPACE MEMBERS ──── export const workspaceMembers = pgTable('workspace_members', { id: text('id').primaryKey().$defaultFn(createId), workspaceId: text('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), role: text('role').notNull().default('member'), // owner | admin | member joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull(), }, (t) => [ uniqueIndex('workspace_members_unique').on(t.workspaceId, t.userId), index('workspace_members_workspace_idx').on(t.workspaceId), ])
// ──── ACCOUNTS (OAuth) ──── export const accounts = pgTable('accounts', { userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), type: text('type').notNull(), provider: text('provider').notNull(), providerAccountId: text('provider_account_id').notNull(), refreshToken: text('refresh_token'), accessToken: text('access_token'), expiresAt: integer('expires_at'), })
// ──── SESSIONS ──── export const sessions = pgTable('sessions', { sessionToken: text('session_token').primaryKey(), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), expires: timestamp('expires', { withTimezone: true }).notNull(), })
Authentication Configuration
// lib/auth.ts import { DrizzleAdapter } from '@auth/drizzle-adapter' import NextAuth from 'next-auth' import Google from 'next-auth/providers/google' import GitHub from 'next-auth/providers/github' import Resend from 'next-auth/providers/resend' import { db } from './db'
export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: DrizzleAdapter(db), providers: [ Google({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }), GitHub({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), Resend({ from: 'noreply@myapp.com', }), ], callbacks: { session: async ({ session, user }) => ({ ...session, user: { ...session.user, id: user.id, }, }), }, pages: { signIn: '/login', error: '/login', }, })
Stripe Billing Integration
Checkout Session
// app/api/billing/checkout/route.ts import { NextResponse } from 'next/server' import { auth } from '@/lib/auth' import { stripe } from '@/lib/stripe' import { db } from '@/lib/db' import { workspaces } from '@/db/schema' import { eq } from 'drizzle-orm'
export async function POST(req: Request) { const session = await auth() if (!session?.user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
const { priceId, workspaceId } = await req.json()
// Get or create Stripe customer const [workspace] = await db.select().from(workspaces).where(eq(workspaces.id, workspaceId)) if (!workspace) { return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) }
let customerId = workspace.stripeCustomerId if (!customerId) { const customer = await stripe.customers.create({ email: session.user.email!, metadata: { workspaceId }, }) customerId = customer.id await db.update(workspaces) .set({ stripeCustomerId: customerId }) .where(eq(workspaces.id, workspaceId)) }
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: ${process.env.NEXT_PUBLIC_APP_URL}/settings/billing?success=true,
cancel_url: ${process.env.NEXT_PUBLIC_APP_URL}/pricing,
subscription_data: { trial_period_days: 14 },
metadata: { workspaceId },
})
return NextResponse.json({ url: checkoutSession.url }) }
Webhook Handler
// app/api/webhooks/stripe/route.ts import { headers } from 'next/headers' import { stripe } from '@/lib/stripe' import { db } from '@/lib/db' import { workspaces } from '@/db/schema' import { eq } from 'drizzle-orm'
export async function POST(req: Request) { const body = await req.text() const signature = (await headers()).get('Stripe-Signature')!
let event
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
} catch (err) {
return new Response(Webhook Error: ${err.message}, { status: 400 })
}
switch (event.type) { case 'checkout.session.completed': { const session = event.data.object const subscription = await stripe.subscriptions.retrieve(session.subscription as string) await db.update(workspaces).set({ stripeSubscriptionId: subscription.id, stripePriceId: subscription.items.data[0].price.id, stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000), }).where(eq(workspaces.stripeCustomerId, session.customer as string)) break }
case 'invoice.payment_succeeded': {
const invoice = event.data.object
const subscription = await stripe.subscriptions.retrieve(invoice.subscription as string)
await db.update(workspaces).set({
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
}).where(eq(workspaces.stripeCustomerId, invoice.customer as string))
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object
await db.update(workspaces).set({
plan: 'free',
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
}).where(eq(workspaces.stripeCustomerId, subscription.customer as string))
break
}
}
return new Response('OK', { status: 200 }) }
Middleware (Auth + Rate Limiting)
// middleware.ts import { auth } from '@/lib/auth' import { NextResponse } from 'next/server'
export default auth((req) => { const { pathname } = req.nextUrl const isAuthenticated = !!req.auth
// Protected routes if (pathname.startsWith('/dashboard') || pathname.startsWith('/settings')) { if (!isAuthenticated) { return NextResponse.redirect(new URL('/login', req.url)) } }
// Redirect logged-in users away from auth pages if ((pathname === '/login' || pathname === '/register') && isAuthenticated) { return NextResponse.redirect(new URL('/dashboard', req.url)) }
return NextResponse.next() })
export const config = { matcher: ['/dashboard/:path*', '/settings/:path*', '/login', '/register'], }
Environment Variables
.env.example
─── App ───
NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXTAUTH_SECRET= # openssl rand -base64 32 NEXTAUTH_URL=http://localhost:3000
─── Database ───
DATABASE_URL= # postgresql://user:pass@host/db?sslmode=require
─── OAuth Providers ───
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET=
─── Stripe ───
STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_PRO_MONTHLY_PRICE_ID=price_... STRIPE_PRO_YEARLY_PRICE_ID=price_...
─── Email ───
RESEND_API_KEY=re_...
─── Monitoring (optional) ───
SENTRY_DSN=
Scaffolding Phases
Execute these phases in order. Validate at the end of each phase.
Phase 1: Foundation
-
Initialize Next.js with TypeScript and App Router
-
Configure Tailwind CSS with custom theme
-
Install and configure shadcn/ui
-
Set up ESLint and Prettier
-
Create .env.example
Validate: pnpm build completes without errors.
Phase 2: Database
-
Install and configure Drizzle ORM
-
Write schema (users, accounts, sessions, workspaces, members)
-
Generate and apply initial migration
-
Export DB client singleton from lib/db.ts
-
Create seed script with test data
Validate: pnpm db:push succeeds and pnpm db:seed creates test data.
Phase 3: Authentication
-
Install and configure NextAuth v5 with Drizzle adapter
-
Set up OAuth providers (Google, GitHub)
-
Create auth API route
-
Implement middleware for route protection
-
Build login and register pages
Validate: OAuth login works, session persists, protected routes redirect.
Phase 4: Billing
-
Initialize Stripe client
-
Create checkout session API route
-
Create customer portal API route
-
Implement webhook handler with signature verification
-
Build pricing page and billing settings page
Validate: Complete a test checkout with card 4242 4242 4242 4242 . Verify subscription data written to DB. Replay webhook event and confirm idempotency.
Phase 5: UI and Polish
-
Build landing page (hero, features, pricing, footer)
-
Build dashboard layout (sidebar, header, stats)
-
Build settings pages (profile, billing, team)
-
Add loading states, error boundaries, and not-found pages
-
Configure deployment (Vercel/Railway)
Validate: pnpm build succeeds. All routes render correctly. No hydration errors.
Multi-Tenancy Patterns
Workspace-Scoped Queries
// Every data query must be scoped to the current workspace export async function getProjects(workspaceId: string) { return db.query.projects.findMany({ where: eq(projects.workspaceId, workspaceId), orderBy: [desc(projects.updatedAt)], }) }
// Middleware: resolve workspace from URL or session export function getCurrentWorkspace(req: Request) { // Option A: workspace slug in URL (/workspace/acme/dashboard) // Option B: workspace ID in session/cookie // Option C: header (X-Workspace-Id) for API calls }
Plan-Based Feature Gating
export function canAccessFeature(workspace: Workspace, feature: string): boolean { const PLAN_FEATURES: Record<string, string[]> = { free: ['basic_dashboard', 'up_to_3_members'], pro: ['advanced_analytics', 'up_to_20_members', 'custom_domain', 'api_access'], enterprise: ['sso', 'unlimited_members', 'audit_log', 'sla'], }
const isActive = workspace.stripeCurrentPeriodEnd ? workspace.stripeCurrentPeriodEnd > new Date() : workspace.plan === 'free'
if (!isActive) return PLAN_FEATURES.free.includes(feature) return PLAN_FEATURES[workspace.plan]?.includes(feature) ?? false }
Common Pitfalls
-
Missing NEXTAUTH_SECRET in production — causes session errors; generate with openssl rand -base64 32
-
Webhook signature verification skipped — always verify Stripe webhook signatures; test with stripe listen
-
workspace:* in session but not refreshed — stale subscription data; recheck on billing pages
-
Edge Runtime conflicts with Drizzle — Drizzle needs Node.js runtime; set export const runtime = 'nodejs' on API routes
-
No idempotent webhook handling — Stripe may send duplicate events; use event.id for deduplication
-
Hardcoded Stripe price IDs — store in env vars, not in code; prices change between test and live mode
Best Practices
-
Stripe singleton — create the client once in lib/stripe.ts , import everywhere
-
Server actions for form mutations — use Next.js Server Actions instead of API routes for forms
-
Idempotent webhook handlers — check if the event was already processed before writing to DB
-
Suspense boundaries for async data — wrap dashboard data in <Suspense> with loading skeletons
-
Feature gating at the server level — check stripeCurrentPeriodEnd on the server, not the client
-
Rate limiting on auth routes — prevent brute force with Upstash Redis + @upstash/ratelimit
-
Workspace context in every query — never query without scoping to the current workspace
-
Test with Stripe CLI — stripe listen --forward-to localhost:3000/api/webhooks/stripe for local development