Claude-Skills saas-scaffolder

install
source · Clone the upstream repo
git clone https://github.com/borghei/Claude-Skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/borghei/Claude-Skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/engineering/saas-scaffolder" ~/.claude/skills/borghei-claude-skills-saas-scaffolder && rm -rf "$T"
manifest: engineering/saas-scaffolder/SKILL.md
source content

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

  1. Initialize Next.js with TypeScript and App Router
  2. Configure Tailwind CSS with custom theme
  3. Install and configure shadcn/ui
  4. Set up ESLint and Prettier
  5. Create
    .env.example

Validate:

pnpm build
completes without errors.

Phase 2: Database

  1. Install and configure Drizzle ORM
  2. Write schema (users, accounts, sessions, workspaces, members)
  3. Generate and apply initial migration
  4. Export DB client singleton from
    lib/db.ts
  5. Create seed script with test data

Validate:

pnpm db:push
succeeds and
pnpm db:seed
creates test data.

Phase 3: Authentication

  1. Install and configure NextAuth v5 with Drizzle adapter
  2. Set up OAuth providers (Google, GitHub)
  3. Create auth API route
  4. Implement middleware for route protection
  5. Build login and register pages

Validate: OAuth login works, session persists, protected routes redirect.

Phase 4: Billing

  1. Initialize Stripe client
  2. Create checkout session API route
  3. Create customer portal API route
  4. Implement webhook handler with signature verification
  5. 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

  1. Build landing page (hero, features, pricing, footer)
  2. Build dashboard layout (sidebar, header, stats)
  3. Build settings pages (profile, billing, team)
  4. Add loading states, error boundaries, and not-found pages
  5. 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

  1. Stripe singleton — create the client once in
    lib/stripe.ts
    , import everywhere
  2. Server actions for form mutations — use Next.js Server Actions instead of API routes for forms
  3. Idempotent webhook handlers — check if the event was already processed before writing to DB
  4. Suspense boundaries for async data — wrap dashboard data in
    <Suspense>
    with loading skeletons
  5. Feature gating at the server level — check
    stripeCurrentPeriodEnd
    on the server, not the client
  6. Rate limiting on auth routes — prevent brute force with Upstash Redis +
    @upstash/ratelimit
  7. Workspace context in every query — never query without scoping to the current workspace
  8. Test with Stripe CLI
    stripe listen --forward-to localhost:3000/api/webhooks/stripe
    for local development

Troubleshooting

ProblemCauseSolution
NEXTAUTH_URL
mismatch errors in production
Environment variable not updated from localhost defaultSet
NEXTAUTH_URL
to your actual production domain; omit trailing slash
Stripe webhook returns 400 on every eventRaw body is consumed before signature verificationEnsure the webhook route uses
req.text()
before any JSON parsing; do not use body-parser middleware on the webhook endpoint
Drizzle migrations fail with "relation already exists"Migration was partially applied or schema drifted from migration historyRun
pnpm drizzle-kit drop
to reset the migration journal, then regenerate with
pnpm drizzle-kit generate
and reapply
OAuth callback redirects to wrong URLRedirect URI registered in provider console does not match
NEXTAUTH_URL
Update the authorized redirect URI in Google/GitHub developer console to match your deployment URL exactly
Multi-tenant queries return data from other workspacesMissing
workspaceId
filter in a database query
Audit all
db.query
and
db.select
calls to ensure every query includes a
where
clause scoped to the current workspace
Hydration mismatch on dashboard pagesServer-rendered HTML differs from client due to conditional auth checksMove auth-dependent rendering into client components or wrap with
<Suspense>
; avoid reading session in server components that also render on the client
Stripe test mode charges succeed but live mode failsLive mode price IDs differ from test mode IDsUse separate environment variables for test vs. live Stripe keys and price IDs; verify
.env.production
references the correct live values

Success Criteria

  • Scaffolded project passes
    pnpm build
    with zero errors and zero TypeScript warnings on first run
  • End-to-end authentication flow (register, login, logout, password reset) completes in under 60 seconds of manual testing
  • Stripe checkout creates a subscription and webhook handler updates the database within 5 seconds of payment completion
  • Multi-tenant data isolation verified: queries scoped to Workspace A return zero rows belonging to Workspace B
  • Lighthouse performance score on the landing page is 90+ on mobile with no accessibility violations at the AA level
  • Time from
    git clone
    to running local dev server with seeded data is under 10 minutes following the generated README
  • All environment variables are documented in
    .env.example
    with descriptions, and the app fails fast with clear error messages when required variables are missing

Scope & Limitations

This skill covers:

  • Full-stack SaaS scaffolding with Next.js App Router, TypeScript, Tailwind, and shadcn/ui
  • Authentication setup with NextAuth v5, Clerk, or Supabase Auth including OAuth and magic link providers
  • Stripe and Lemon Squeezy billing integration with checkout, webhooks, and customer portal
  • Multi-tenancy patterns (workspace/organization) with role-based access and plan-based feature gating

This skill does NOT cover:

  • Ongoing Stripe billing logic beyond initial integration (metered billing, usage-based pricing, invoicing customization) — see
    stripe-integration-expert
  • Database schema design decisions beyond the core tenancy model (complex relational modeling, indexing strategies) — see
    database-schema-designer
  • CI/CD pipeline configuration, deployment automation, or infrastructure provisioning — see
    ci-cd-pipeline-builder
  • API design standards, versioning, or OpenAPI specification generation — see
    api-design-reviewer

Integration Points

SkillIntegrationData Flow
stripe-integration-expert
Extends the scaffolded Stripe setup with advanced billing patterns (metered, tiered, usage-based)Scaffolder outputs base Stripe config and webhook handler; Stripe expert refines pricing models and adds invoice customization
database-schema-designer
Designs extended schemas beyond the core tenancy tablesScaffolder provides baseline users/workspaces/members schema; schema designer adds domain-specific entities and optimizes indexes
api-design-reviewer
Reviews and improves the generated API routes for consistency and standards complianceScaffolder generates initial API routes; reviewer audits naming, error handling, and response formats
ci-cd-pipeline-builder
Creates deployment pipelines for the scaffolded projectScaffolder outputs the application code; pipeline builder adds GitHub Actions, preview deployments, and production release workflows
env-secrets-manager
Audits and secures the environment variable configurationScaffolder generates
.env.example
; secrets manager validates no secrets are hardcoded and recommends vault integration
observability-designer
Adds logging, tracing, and monitoring to the scaffolded applicationScaffolder provides the application structure; observability designer instruments API routes, webhooks, and auth flows