How to Build a SaaS Product with Next.js: From Idea to Launch
A comprehensive blueprint for building and launching a SaaS product using Next.js 15, Stripe, authentication, and modern deployment.
Building a SaaS product from scratch can feel overwhelming. Between authentication, payments, databases, and deployment, there are dozens of decisions to make before you write a single line of code.
This guide provides a complete blueprint for building a SaaS with Next.js 15.
The Architecture
Here's the high-level architecture for a modern SaaS:
Client (Browser)
↕
Next.js 15 (App Router)
↕
API Routes / Server Actions
↕
Authentication ←→ Database ←→ Payment Provider
(NextAuth) (PostgreSQL) (Stripe)
↕
Third-party Services (Resend, Uploadthing, etc.)
1. Project Setup
npx create-next-app@latest mysaas --typescript --tailwind --app
npm install @prisma/client next-auth @stripe/stripe-js @stripe/react-stripe-js
npm install -D prisma
2. Authentication with NextAuth
Set up authentication with multiple providers:
// lib/auth.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Google from 'next-auth/providers/google'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from './prisma'
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
callbacks: {
session({ session, user }) {
session.user.id = user.id
return session
},
},
pages: {
signIn: '/auth/signin',
},
})
Protecting Routes with Middleware
// middleware.ts
import { auth } from '@/lib/auth'
import { NextResponse } from 'next/server'
export default auth((req) => {
const isLoggedIn = !!req.auth
const isOnDashboard = req.nextUrl.pathname.startsWith('/dashboard')
if (isOnDashboard && !isLoggedIn) {
return NextResponse.redirect(new URL('/auth/signin', req.url))
}
return NextResponse.next()
})
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
3. Database Schema
Here's the Prisma schema for a typical SaaS:
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
customerId String? @unique // Stripe customer ID
subscriptionId String? @unique // Stripe subscription ID
subscribed Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Subscription {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
stripeId String @unique
status String // active, canceled, past_due, etc.
priceId String
currentPeriodStart DateTime
currentPeriodEnd DateTime
createdAt DateTime @default(now())
}
4. Stripe Integration
Checkout Session
// app/api/stripe/checkout/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: Request) {
const body = await request.json()
const { priceId, userId, userEmail } = body
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
customer_email: userEmail,
client_reference_id: userId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing?canceled=true`,
metadata: { userId },
})
return NextResponse.json({ url: session.url })
}
Webhook Handler
// app/api/stripe/webhook/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { prisma } from '@/lib/prisma'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: Request) {
const body = await request.text()
const signature = request.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
const userId = session.metadata?.userId
if (userId) {
await prisma.user.update({
where: { id: userId },
data: {
customerId: session.customer as string,
subscriptionId: session.subscription as string,
subscribed: true,
},
})
}
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
const customerId = subscription.customer as string
await prisma.user.update({
where: { customerId },
data: {
subscribed: false,
subscriptionId: null,
},
})
break
}
}
return NextResponse.json({ received: true })
}
5. Pricing Page
Create a pricing page that shows available plans:
// app/pricing/page.tsx
const plans = [
{
name: 'Starter',
price: '$19',
features: ['5 Projects', '10GB Storage', 'Basic Support'],
priceId: 'price_starter_id',
},
{
name: 'Pro',
price: '$49',
features: ['Unlimited Projects', '100GB Storage', 'Priority Support', 'API Access'],
priceId: 'price_pro_id',
popular: true,
},
{
name: 'Enterprise',
price: '$99',
features: ['Everything in Pro', 'Unlimited Storage', 'Dedicated Support', 'Custom Integrations'],
priceId: 'price_enterprise_id',
},
]
6. Dashboard with Subscription Status
// app/dashboard/page.tsx
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const session = await auth()
if (!session?.user?.id) redirect('/auth/signin')
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { subscribed: true, subscriptionId: true },
})
if (!user?.subscribed) {
return (
<div>
<h1>Upgrade to access the dashboard</h1>
<a href="/pricing">View Plans</a>
</div>
)
}
return (
<div>
<h1>Dashboard</h1>
{/* Subscription-based content */}
</div>
)
}
7. Email Integration
Send transactional emails with Resend:
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: 'SaaS <hello@yourdomain.com>',
to: email,
subject: 'Welcome to SaaS!',
html: `<h1>Welcome, ${name}!</h1><p>Thanks for signing up.</p>`,
})
}
8. Deployment Checklist
Before launching, ensure:
- Environment variables configured in Vercel
- Database migrations run in production
- Stripe webhook endpoints set up
- Custom domain configured
- SSL certificate active
- Error monitoring (Sentry) configured
- Analytics (PostHog or Plausible) set up
- Terms of Service and Privacy Policy published
- Rate limiting on API routes
- Backup strategy for database
9. Monitoring and Analytics
// app/providers.tsx
'use client'
import posthog from 'posthog-js'
import { PostHogProvider } from 'posthog-js/react'
if (typeof window !== 'undefined') {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
})
}
export function Providers({ children }: { children: React.ReactNode }) {
return <PostHogProvider client={posthog}>{children}</PostHogProvider>
}
Conclusion
Building a SaaS with Next.js is more accessible than ever. The App Router, Server Components, and the ecosystem of tools like NextAuth, Prisma, and Stripe provide a solid foundation.
Start with the MVP — authentication, a single subscription tier, and the core feature. Launch fast, gather feedback, and iterate. The architecture outlined here scales from your first user to your millionth.