February 19, 2026 · 18 min
Building a Full-Stack SaaS from Scratch with Next.js and PostgreSQL
A complete end-to-end tutorial: building a production-ready SaaS application from zero — project setup, authentication, database, subscriptions, and deployment.
Building a Full-Stack SaaS from Scratch with Next.js and PostgreSQL
TL;DR: This is the complete guide I used as a mental framework building Smart LMS, SmartStore SaaS, and AutomateLanka. We'll build a real SaaS product — user auth, database, Stripe subscriptions, and deploy to Vercel.
What We're Building
A SaaS boilerplate with:
- ✅ Next.js 15 with App Router
- ✅ PostgreSQL + Prisma ORM
- ✅ NextAuth.js (email + Google OAuth)
- ✅ Stripe subscriptions (Free, Pro, Enterprise)
- ✅ Multi-tenant data isolation
- ✅ Protected dashboard
- ✅ Deployment to Vercel + Supabase
Estimated time: 4–6 hours to follow along, 2–3 days for a real production product.
Step 1: Project Setup
# Create Next.js app
npx create-next-app@latest my-saas \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir \
--import-alias "@/*"
cd my-saas
# Install dependencies
npm install \
prisma @prisma/client \
next-auth @auth/prisma-adapter \
stripe \
@stripe/stripe-js \
resend \
zod \
@tanstack/react-query \
zustand \
lucide-react
npm install -D \
@types/node \
prettier \
prettier-plugin-tailwindcss
Project structure we'll build:
src/
├── app/
│ ├── (auth)/ # Login, register pages
│ ├── (dashboard)/ # Protected app pages
│ ├── (marketing)/ # Public landing pages
│ └── api/ # API routes
├── features/ # Feature modules
├── lib/ # Shared utilities
└── types/ # TypeScript types
Step 2: Database Setup with Prisma
# Initialize Prisma
npx prisma init --datasource-provider postgresql
Create your schema:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Plan {
FREE
PRO
ENTERPRISE
}
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
// Subscription
plan Plan @default(FREE)
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
stripePriceId String?
stripeCurrentPeriodEnd DateTime?
// Relations
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
# Run your first migration
npx prisma migrate dev --name init
npx prisma generate
Step 3: Authentication with NextAuth.js
// src/lib/auth.ts
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import Google from "next-auth/providers/google";
import Resend from "next-auth/providers/resend";
import { prisma } from "@/lib/db";
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
Resend({
apiKey: process.env.RESEND_API_KEY!,
from: "noreply@yoursaas.com",
}),
],
callbacks: {
session({ session, user }) {
session.user.id = user.id;
session.user.plan = user.plan;
return session;
},
},
pages: {
signIn: "/login",
error: "/login",
},
});
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
// src/middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isDashboard = req.nextUrl.pathname.startsWith('/dashboard');
if (isDashboard && !isLoggedIn) {
return NextResponse.redirect(new URL('/login', req.url));
}
return NextResponse.next();
});
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
Step 4: Stripe Subscriptions
Create Plans in Stripe Dashboard
Create three products: Free (no charge), Pro ($29/mo), Enterprise ($99/mo).
Checkout Session
// src/app/api/stripe/create-checkout/route.ts
import Stripe from "stripe";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const PRICE_IDS = {
PRO: process.env.STRIPE_PRO_PRICE_ID!,
ENTERPRISE: process.env.STRIPE_ENTERPRISE_PRICE_ID!,
};
export async function POST(request: Request) {
const session = await auth();
if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { plan } = await request.json();
const user = await prisma.user.findUnique({
where: { id: session.user.id },
});
// Create or retrieve Stripe customer
let customerId = user!.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user!.email!,
name: user!.name || undefined,
metadata: { userId: user!.id },
});
customerId = customer.id;
await prisma.user.update({
where: { id: user!.id },
data: { stripeCustomerId: customer.id },
});
}
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [{ price: PRICE_IDS[plan as keyof typeof PRICE_IDS], quantity: 1 }],
mode: "subscription",
success_url: `${process.env.NEXTAUTH_URL}/dashboard?upgrade=success`,
cancel_url: `${process.env.NEXTAUTH_URL}/pricing`,
metadata: { userId: user!.id, plan },
});
return Response.json({ url: checkoutSession.url });
}
Stripe Webhook Handler
// src/app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";
import { prisma } from "@/lib/db";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text();
const signature = headers().get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
const session = event.data.object as Stripe.Checkout.Session;
if (event.type === "checkout.session.completed") {
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
await prisma.user.update({
where: { id: session.metadata!.userId },
data: {
plan: session.metadata!.plan as "PRO" | "ENTERPRISE",
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
}
if (event.type === "customer.subscription.deleted") {
const sub = event.data.object as Stripe.Subscription;
await prisma.user.update({
where: { stripeCustomerId: sub.customer as string },
data: { plan: "FREE", stripeSubscriptionId: null, stripePriceId: null },
});
}
return new Response("OK");
}
Step 5: Plan Feature Gating
// src/lib/plan-gate.ts
export const PLAN_LIMITS = {
FREE: { maxProjects: 2, maxStorage: '500MB', aiAccess: false },
PRO: { maxProjects: 20, maxStorage: '10GB', aiAccess: true },
ENTERPRISE: { maxProjects: -1, maxStorage: 'unlimited', aiAccess: true },
} as const;
// Use in Server Components
import { auth } from "@/lib/auth";
export async function checkPlanLimit(
resource: 'maxProjects',
currentCount: number
) {
const session = await auth();
const plan = session!.user.plan;
const limit = PLAN_LIMITS[plan][resource];
if (limit === -1) return { allowed: true };
return { allowed: currentCount < limit, limit };
}
// In a Server Component
export default async function NewProjectButton() {
const projectCount = await getProjectCount();
const { allowed, limit } = await checkPlanLimit('maxProjects', projectCount);
if (!allowed) {
return <UpgradePrompt message={`You've reached the ${limit} project limit. Upgrade to Pro.`} />;
}
return <CreateProjectButton />;
}
Step 6: Protected Dashboard Layout
// src/app/(dashboard)/layout.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { Sidebar } from "@/components/sidebar";
export default async function DashboardLayout({
children,
}: { children: React.ReactNode }) {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return (
<div className="flex h-screen">
<Sidebar user={session.user} />
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
);
}
Step 7: Environment Variables
Create .env.local:
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
# Auth
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="generate-with-openssl-rand-base64-32"
# Google OAuth
GOOGLE_CLIENT_ID="..."
GOOGLE_CLIENT_SECRET="..."
# Email (Resend)
RESEND_API_KEY="re_..."
# Stripe
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
STRIPE_PRO_PRICE_ID="price_..."
STRIPE_ENTERPRISE_PRICE_ID="price_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
Step 8: Deploy to Vercel + Supabase
# Install Vercel CLI
npm install -g vercel
# Link to Supabase (PostgreSQL)
# 1. Create project at supabase.com
# 2. Get connection string from Settings → Database
# Deploy
vercel
# Set environment variables
vercel env add DATABASE_URL
vercel env add NEXTAUTH_SECRET
# ... add all env vars
# Run migrations on production
vercel env pull .env.production.local
DATABASE_URL="your-production-url" npx prisma migrate deploy
Stripe Webhook on Production
# Add webhook in Stripe Dashboard
# Endpoint: https://yoursaas.vercel.app/api/webhooks/stripe
# Events: checkout.session.completed, customer.subscription.deleted
# Test webhooks locally with Stripe CLI
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed
What You Have Now
After following this guide:
- ✅ Users can sign up with Google or magic link email
- ✅ Protected dashboard only visible to authenticated users
- ✅ Stripe checkout for Pro/Enterprise plans
- ✅ Webhook handler updating user plan in DB
- ✅ Feature gating based on plan
- ✅ Running on Vercel with Supabase PostgreSQL
Next steps to make it a real product:
- Build your actual SaaS feature (the core value proposition)
- Add usage tracking per user
- Build an admin dashboard
- Add email notifications (Resend)
- Set up error monitoring (Sentry)
This framework powers Smart LMS, SmartStore SaaS, and AutomateLanka. See them live at my portfolio.