Build a SaaS Starter Kit

SaaS Project Setup & Architecture

5 min read

What You'll Build

  • Next.js 15 with App Router and TypeScript
  • Tailwind CSS with a professional design system
  • PostgreSQL database with Drizzle ORM
  • Project structure optimized for AI-assisted development
  • Environment configuration for development and production

Time to complete: ~30 minutes with AI assistance (vs 2-3 hours manually)


Step 1: Initialize with AI

Open your terminal and start Claude Code (or Cursor with Agent mode). Use this prompt:

Create a new Next.js 15 SaaS starter with:
- TypeScript in strict mode
- Tailwind CSS with a custom color palette (primary: indigo, secondary: slate)
- App Router with (marketing), (dashboard), and (auth) route groups
- PostgreSQL with Drizzle ORM
- shadcn/ui components (button, card, input, dialog)
- Environment variables setup (.env.example)

Project name: my-saas-starter
Use pnpm as package manager

What AI does:

  1. Runs pnpm create next-app with correct flags
  2. Installs and configures Tailwind with custom theme
  3. Sets up Drizzle ORM with PostgreSQL adapter
  4. Creates route group structure
  5. Initializes shadcn/ui
  6. Creates .env.example with required variables

Step 2: Project Structure

After AI completes setup, you should have:

my-saas-starter/
├── src/
│   ├── app/
│   │   ├── (marketing)/          # Landing, pricing, about
│   │   │   ├── page.tsx          # Home page
│   │   │   ├── pricing/
│   │   │   └── layout.tsx
│   │   ├── (dashboard)/          # Protected app pages
│   │   │   ├── dashboard/
│   │   │   ├── settings/
│   │   │   └── layout.tsx
│   │   ├── (auth)/               # Login, signup, forgot password
│   │   │   ├── login/
│   │   │   ├── signup/
│   │   │   └── layout.tsx
│   │   ├── api/                  # API routes
│   │   ├── layout.tsx            # Root layout
│   │   └── globals.css
│   ├── components/
│   │   ├── ui/                   # shadcn components
│   │   ├── marketing/            # Landing page components
│   │   └── dashboard/            # Dashboard components
│   ├── lib/
│   │   ├── db/                   # Database client & schema
│   │   ├── auth/                 # Auth utilities
│   │   └── utils.ts
│   └── types/
├── drizzle/                      # Migrations
├── public/
├── .env.example
├── drizzle.config.ts
├── tailwind.config.ts
└── package.json

Step 3: Database Schema Design

Ask AI to create your initial database schema:

Create a Drizzle schema for a multi-tenant SaaS with:
- users table (id, email, name, avatar, created_at, updated_at)
- organizations table (id, name, slug, owner_id, plan, created_at)
- organization_members table (user_id, org_id, role: owner/admin/member)
- subscriptions table (id, org_id, stripe_customer_id, stripe_subscription_id, status, plan, current_period_end)

Use UUID for IDs, proper indexes, and foreign key relationships.
File: src/lib/db/schema.ts

AI generates:

// src/lib/db/schema.ts
import { pgTable, uuid, text, timestamp, pgEnum, index } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

// Enums
export const memberRoleEnum = pgEnum('member_role', ['owner', 'admin', 'member']);
export const subscriptionStatusEnum = pgEnum('subscription_status', [
  'active', 'canceled', 'past_due', 'trialing', 'incomplete'
]);
export const planEnum = pgEnum('plan', ['free', 'starter', 'pro', 'enterprise']);

// Users
export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: text('email').notNull().unique(),
  name: text('name'),
  avatarUrl: text('avatar_url'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => ({
  emailIdx: index('users_email_idx').on(table.email),
}));

// Organizations
export const organizations = pgTable('organizations', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: text('name').notNull(),
  slug: text('slug').notNull().unique(),
  ownerId: uuid('owner_id').references(() => users.id).notNull(),
  plan: planEnum('plan').default('free').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => ({
  slugIdx: index('orgs_slug_idx').on(table.slug),
  ownerIdx: index('orgs_owner_idx').on(table.ownerId),
}));

// Organization Members
export const organizationMembers = pgTable('organization_members', {
  userId: uuid('user_id').references(() => users.id).notNull(),
  organizationId: uuid('organization_id').references(() => organizations.id).notNull(),
  role: memberRoleEnum('role').default('member').notNull(),
  joinedAt: timestamp('joined_at').defaultNow().notNull(),
}, (table) => ({
  pk: index('org_members_pk').on(table.userId, table.organizationId),
}));

// Subscriptions
export const subscriptions = pgTable('subscriptions', {
  id: uuid('id').primaryKey().defaultRandom(),
  organizationId: uuid('organization_id').references(() => organizations.id).notNull(),
  stripeCustomerId: text('stripe_customer_id'),
  stripeSubscriptionId: text('stripe_subscription_id'),
  status: subscriptionStatusEnum('status').default('trialing').notNull(),
  plan: planEnum('plan').default('free').notNull(),
  currentPeriodEnd: timestamp('current_period_end'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => ({
  orgIdx: index('subscriptions_org_idx').on(table.organizationId),
  stripeSubIdx: index('subscriptions_stripe_idx').on(table.stripeSubscriptionId),
}));

// Relations
export const usersRelations = relations(users, ({ many }) => ({
  ownedOrganizations: many(organizations),
  memberships: many(organizationMembers),
}));

export const organizationsRelations = relations(organizations, ({ one, many }) => ({
  owner: one(users, {
    fields: [organizations.ownerId],
    references: [users.id],
  }),
  members: many(organizationMembers),
  subscription: one(subscriptions),
}));

Step 4: Environment Configuration

Create proper environment handling:

Create environment configuration with:
1. .env.example with all required variables (documented)
2. src/lib/env.ts with Zod validation for type-safe env access
3. Include: DATABASE_URL, NEXTAUTH_*, STRIPE_*, APP_URL

AI generates:

// src/lib/env.ts
import { z } from 'zod';

const envSchema = z.object({
  // Database
  DATABASE_URL: z.string().url(),

  // Auth
  NEXTAUTH_SECRET: z.string().min(32),
  NEXTAUTH_URL: z.string().url(),

  // OAuth Providers
  GOOGLE_CLIENT_ID: z.string().optional(),
  GOOGLE_CLIENT_SECRET: z.string().optional(),
  GITHUB_CLIENT_ID: z.string().optional(),
  GITHUB_CLIENT_SECRET: z.string().optional(),

  // Stripe
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),

  // App
  NEXT_PUBLIC_APP_URL: z.string().url(),

  // Optional
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
});

// Parse and validate
const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error('❌ Invalid environment variables:');
  console.error(parsed.error.flatten().fieldErrors);
  throw new Error('Invalid environment variables');
}

export const env = parsed.data;

Step 5: Design System Setup

Ask AI to create a consistent design system:

Create a Tailwind design system config with:
- Primary color: Indigo (for CTAs, links)
- Secondary color: Slate (for text, backgrounds)
- Success/Warning/Error colors
- Typography scale (display, heading, body, small)
- Spacing consistent with 4px grid
- Border radius tokens (sm, md, lg, xl, full)
- Shadow tokens (sm, md, lg, glow)
- Animation durations

Update tailwind.config.ts with these tokens.

Step 6: Create Base Components

Use AI to generate foundational components:

Create these base components in src/components/:

1. ui/container.tsx - Max-width centered container with responsive padding
2. ui/section.tsx - Page section with vertical spacing
3. ui/heading.tsx - Typography component with size variants
4. marketing/navbar.tsx - Marketing site header with mobile menu
5. marketing/footer.tsx - Site footer with links
6. dashboard/sidebar.tsx - Dashboard navigation sidebar
7. dashboard/header.tsx - Dashboard top bar with user menu

Use shadcn/ui components where appropriate.
Follow the design system tokens.
Include TypeScript types for all props.

Step 7: Database Setup

Run the initial migration:

# Create the database (using Docker for local dev)
docker run --name saas-postgres -e POSTGRES_PASSWORD=password -e POSTGRES_DB=saas -p 5432:5432 -d postgres:16

# Generate migration
pnpm drizzle-kit generate

# Push to database
pnpm drizzle-kit push

Ask AI to create a database utility:

Create src/lib/db/index.ts with:
- Database client singleton
- Helper function for transactions
- Connection pooling configuration for serverless (Neon/Vercel Postgres)

Step 8: Verify Setup

Ask AI to create a simple test page:

Create a test page at src/app/(marketing)/page.tsx that:
- Uses the Container and Section components
- Shows the Navbar and Footer
- Displays a hero section with headline, subheadline, and CTA button
- Shows 3 feature cards using shadcn Card component
- Is fully responsive

Hero text:
- Headline: "Build faster with AI-powered development"
- Subheadline: "The modern SaaS starter kit for vibe coders"
- CTA: "Get Started Free"

Run the dev server:

pnpm dev

Visit http://localhost:3000 to see your foundation.


Key Takeaways

  1. AI accelerates scaffolding - Setup that takes hours manually takes minutes with AI
  2. Structure matters - Route groups and organized folders make AI context more effective
  3. Type safety pays off - TypeScript and Zod validation catch errors early
  4. Design systems scale - Tokens ensure consistency as your app grows
  5. Database design first - Well-designed schemas save refactoring later

What's Next

In the next lesson, you'll add authentication and user management to your SaaS foundation using NextAuth.js with multiple providers.

:::

Quiz

Module 1: Build a SaaS Starter Kit

Take Quiz