← Back to tech insights

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:

  1. Build your actual SaaS feature (the core value proposition)
  2. Add usage tracking per user
  3. Build an admin dashboard
  4. Add email notifications (Resend)
  5. Set up error monitoring (Sentry)

This framework powers Smart LMS, SmartStore SaaS, and AutomateLanka. See them live at my portfolio.