How to Build a SaaS with Next.js (2026 Guide)
This is the practical, step-by-step guide to building a SaaS with Next.js. Not theory — the actual decisions, tools, and code patterns you need. From zero to launch.
The Architecture
Next.js App Router
├── (marketing) → Landing, pricing, blog (public)
├── (auth) → Sign in, sign up (Clerk)
├── (dashboard) → Protected app pages
├── api/
│ ├── webhooks/ → Stripe, Clerk webhooks
│ └── ... → API routes
├── lib/
│ ├── db.ts → Drizzle + Neon
│ ├── stripe.ts → Stripe client
│ └── auth.ts → Clerk helpers
└── components/ → shadcn/ui components
Step 1: Project Setup (15 minutes)
npx create-next-app@latest my-saas --typescript --tailwind --eslint --app --src-dir
cd my-saas
# UI components
npx shadcn@latest init
npx shadcn@latest add button card dialog input form table
# Core dependencies
npm install @clerk/nextjs @neondatabase/serverless drizzle-orm stripe resend
npm install -D drizzle-kit
Step 2: Database (30 minutes)
Set up Neon
- Create account at neon.tech (free)
- Create a project → copy the connection string
- Add to
.env.local:DATABASE_URL=your-connection-string
Define Schema with Drizzle
// src/lib/db/schema.ts
import { pgTable, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: text('id').primaryKey(), // Clerk user ID
email: text('email').notNull().unique(),
name: text('name'),
stripeCustomerId: text('stripe_customer_id'),
plan: text('plan').default('free').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
export const projects = pgTable('projects', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text('name').notNull(),
userId: text('user_id').references(() => users.id).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
Database Client
// src/lib/db/index.ts
import { neon } from '@neondatabase/serverless'
import { drizzle } from 'drizzle-orm/neon-http'
import * as schema from './schema'
const sql = neon(process.env.DATABASE_URL!)
export const db = drizzle(sql, { schema })
Run Migrations
npx drizzle-kit generate
npx drizzle-kit migrate
Step 3: Authentication (20 minutes)
Set up Clerk
- Create account at clerk.com (free up to 10K MAU)
- Copy API keys to
.env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_...
CLERK_SECRET_KEY=sk_...
Add Middleware
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
const isProtectedRoute = createRouteMatcher(['/dashboard(.*)'])
export default clerkMiddleware((auth, req) => {
if (isProtectedRoute(req)) auth().protect()
})
export const config = {
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
}
Sync Users to Database
// src/app/api/webhooks/clerk/route.ts
import { WebhookEvent } from '@clerk/nextjs/server'
import { db } from '@/lib/db'
import { users } from '@/lib/db/schema'
export async function POST(req: Request) {
const event: WebhookEvent = await req.json()
if (event.type === 'user.created') {
await db.insert(users).values({
id: event.data.id,
email: event.data.email_addresses[0].email_address,
name: `${event.data.first_name} ${event.data.last_name}`,
})
}
return new Response('OK')
}
Step 4: Payments with Stripe (45 minutes)
Create Checkout Session
// src/app/api/stripe/checkout/route.ts
import { auth } from '@clerk/nextjs/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const { userId } = auth()
if (!userId) return new Response('Unauthorized', { status: 401 })
const { priceId } = await req.json()
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
metadata: { userId },
})
return Response.json({ url: session.url })
}
Handle Webhooks
// src/app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
import { db } from '@/lib/db'
import { users } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const body = await req.text()
const sig = req.headers.get('stripe-signature')!
const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object
await db.update(users)
.set({ plan: 'pro', stripeCustomerId: session.customer as string })
.where(eq(users.id, session.metadata!.userId!))
break
}
case 'customer.subscription.deleted': {
const sub = event.data.object
await db.update(users)
.set({ plan: 'free' })
.where(eq(users.stripeCustomerId, sub.customer as string))
break
}
}
return new Response('OK')
}
Step 5: Dashboard Layout
// src/app/(dashboard)/layout.tsx
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'
import { Sidebar } from '@/components/sidebar'
export default async function DashboardLayout({ children }) {
const { userId } = auth()
if (!userId) redirect('/sign-in')
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 overflow-auto p-8">{children}</main>
</div>
)
}
Step 6: Email with Resend (15 minutes)
// src/lib/email.ts
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
export async function sendWelcomeEmail(email: string, name: string) {
await resend.emails.send({
from: 'YourApp <hello@yourapp.com>',
to: email,
subject: 'Welcome to YourApp!',
html: `<h1>Welcome, ${name}!</h1><p>Get started by creating your first project.</p>`,
})
}
Step 7: Deploy (5 minutes)
# Push to GitHub, then:
npx vercel --prod
Or connect your GitHub repo to Vercel for automatic deployments.
The Pricing Page Pattern
const plans = [
{
name: 'Free',
price: '$0',
features: ['3 projects', 'Basic analytics', 'Community support'],
priceId: null,
},
{
name: 'Pro',
price: '$19/mo',
features: ['Unlimited projects', 'Advanced analytics', 'Priority support', 'API access'],
priceId: 'price_xxx',
popular: true,
},
]
Common Patterns
Check User Plan (Server Component)
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
})
if (user?.plan === 'free' && projectCount >= 3) {
return <UpgradePrompt />
}
Rate Limiting
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'),
})
const { success } = await ratelimit.limit(userId)
if (!success) return new Response('Too many requests', { status: 429 })
Launch Checklist
- Auth working (sign up, sign in, protected routes)
- Stripe payments working (checkout, webhooks, plan upgrades)
- Core feature working (the thing users pay for)
- Email sending (welcome, receipts)
- Error monitoring (Sentry)
- Analytics (PostHog or Vercel Analytics)
- Custom domain
- Terms of service and privacy policy
- Pricing page
- Landing page with clear value proposition
FAQ
How long does it take to build a SaaS?
With this stack, 2-4 weeks from zero to MVP. Auth + payments + database takes 1-2 days. The rest is your core feature.
Should I use a boilerplate?
Boilerplates (Shipfast, Supastarter) save 1-2 weeks of setup. Worth it if you want to skip directly to building your core feature.
When should I start charging?
Day one. Even if it's $5/month. Paying users give real feedback. Free users give opinions.
How do I handle multi-tenancy?
Shared database with userId or orgId on every table. Clerk's Organizations feature handles team management.
Bottom Line
Next.js + Clerk + Stripe + Neon + Vercel. That's the stack. You can go from zero to a deployed SaaS with auth and payments in a single weekend. The hard part isn't the tech — it's building something people will pay for. Ship fast, iterate faster.