Building a SaaS product requires more than just a frontend — you need authentication, billing, database multi-tenancy, and a deployment pipeline that scales. Next.js gives you server components, API routes, and middleware that handle all of these concerns in a single framework. This guide walks you through building a functional SaaS application with user authentication, Stripe subscription billing, and a PostgreSQL database.
Prerequisites
- →Node.js 20+
Next.js 15 requires Node.js 20 or later for full compatibility with server components and the App Router.
- →Stripe Account
You need a Stripe account (free to create) with API keys for handling subscription billing.
- →PostgreSQL Database
A PostgreSQL instance for storing users, subscriptions, and application data. Neon or Supabase offer free tiers.
- →Basic TypeScript Knowledge
Familiarity with TypeScript interfaces, generics, and async/await patterns.
Scaffold the Next.js Project with TypeScript
Create a new Next.js application using the App Router with TypeScript, Tailwind CSS, and ESLint preconfigured. The App Router gives you React Server Components by default, which reduces client-side JavaScript and improves performance for your SaaS landing pages and dashboard.
npx create-next-app@latest my-saas \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir \
--import-alias "@/*"
cd my-saas
npm install @prisma/client stripe next-auth@beta
npm install -D prismaTip: Use the --src-dir flag to keep your project root clean as the codebase grows.
Tip: Pin your Next.js version in package.json to avoid unexpected breaking changes in production.
Set Up Prisma with a Multi-Tenant Schema
Initialize Prisma and define your database schema with User, Account, Subscription, and Organization models. The Organization model enables multi-tenancy so each customer's data stays isolated. Prisma gives you type-safe database queries that integrate perfectly with TypeScript.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
organizationId String?
organization Organization? @relation(fields: [organizationId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Organization {
id String @id @default(cuid())
name String
stripeCustomerId String? @unique
users User[]
subscription Subscription?
createdAt DateTime @default(now())
}
model Subscription {
id String @id @default(cuid())
organizationId String @unique
organization Organization @relation(fields: [organizationId], references: [id])
stripeSubscriptionId String @unique
stripePriceId String
status String
currentPeriodEnd DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Tip: Run 'npx prisma db push' during development for quick schema changes, and switch to migrations before production.
Tip: Add an index on organizationId for every table that stores tenant data to keep queries fast.
Configure Authentication with NextAuth.js
Set up NextAuth.js v5 with the Prisma adapter to handle user sign-up, login, and session management. NextAuth supports OAuth providers, magic links, and credentials-based auth. Using the Prisma adapter means user sessions are stored in your database automatically.
// src/lib/auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
],
callbacks: {
async session({ session, user }) {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
include: { organization: { include: { subscription: true } } },
});
session.user.organizationId = dbUser?.organizationId ?? null;
session.user.plan = dbUser?.organization?.subscription?.status ?? "free";
return session;
},
},
});Tip: Add multiple OAuth providers (Google, GitHub) to reduce friction during sign-up.
Tip: Store the user's subscription status in the session to avoid extra database queries on every page load.
Build the Pricing Page with Stripe Checkout
Create a pricing page that displays your subscription tiers and redirects users to Stripe Checkout when they select a plan. Stripe Checkout handles the entire payment flow including card validation, 3D Secure, and receipts. You define your products and prices in the Stripe Dashboard and reference them by price ID.
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { auth } from "@/lib/auth";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { priceId } = await req.json();
const checkoutSession = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
customer_email: session.user.email,
success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing?canceled=true`,
metadata: {
userId: session.user.id,
},
});
return NextResponse.json({ url: checkoutSession.url });
}Tip: Always use Stripe Checkout instead of building your own payment form — it handles PCI compliance for you.
Tip: Test with Stripe's test card number 4242424242424242 before going live.
Handle Stripe Webhooks for Subscription Events
Create a webhook endpoint that listens for Stripe events like successful payments, subscription cancellations, and payment failures. This keeps your database in sync with Stripe's billing state. Stripe signs every webhook payload so you can verify its authenticity.
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, 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(req: NextRequest) {
const body = await req.text();
const signature = req.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 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
await prisma.subscription.upsert({
where: { stripeSubscriptionId: subscription.id },
update: {
status: subscription.status,
stripePriceId: subscription.items.data[0].price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
create: {
stripeSubscriptionId: subscription.id,
organizationId: session.metadata!.organizationId!,
status: subscription.status,
stripePriceId: subscription.items.data[0].price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
}
return NextResponse.json({ received: true });
}Tip: Use the Stripe CLI to forward webhooks to localhost during development: 'stripe listen --forward-to localhost:3000/api/webhooks/stripe'.
Tip: Always handle idempotency — Stripe may send the same event more than once.
Build the Dashboard with Role-Based Access
Create a protected dashboard layout using Next.js middleware that checks authentication and subscription status. Server components fetch data directly from Prisma without an API layer, reducing latency. The dashboard layout includes a sidebar, header, and content area that adapts based on the user's plan.
// src/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("/login", req.url));
}
return NextResponse.next();
});
export const config = {
matcher: ["/dashboard/:path*"],
};Tip: Use Next.js middleware for authentication checks — it runs on the edge and blocks unauthorized requests before they reach your server components.
Tip: Fetch subscription data in the dashboard layout (not each page) so child routes inherit the billing context.
Add a Customer Portal for Billing Management
Integrate Stripe's Customer Portal so users can update their payment method, change plans, and view invoices without you building any billing UI. You create a portal session and redirect the user to it. Stripe handles everything and redirects them back to your app when they're done.
// src/app/api/portal/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST() {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: { organization: true },
});
if (!user?.organization?.stripeCustomerId) {
return NextResponse.json({ error: "No billing account" }, { status: 400 });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.organization.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_URL}/dashboard/settings`,
});
return NextResponse.json({ url: portalSession.url });
}Tip: Configure your Stripe Customer Portal settings in the Stripe Dashboard to control which actions users can take.
Tip: Add a 'Manage Billing' button in your dashboard settings page that calls this endpoint.
Deploy to Vercel with Environment Variables
Deploy your SaaS to Vercel with all required environment variables configured. Vercel's integration with Next.js gives you automatic preview deployments for every pull request, edge middleware, and serverless functions. Set up your production domain and configure Stripe webhooks to point to it.
# Install Vercel CLI and deploy
npm install -g vercel
vercel
# Set environment variables
vercel env add DATABASE_URL
vercel env add STRIPE_SECRET_KEY
vercel env add STRIPE_WEBHOOK_SECRET
vercel env add GITHUB_ID
vercel env add GITHUB_SECRET
vercel env add NEXTAUTH_SECRET
vercel env add NEXT_PUBLIC_URL
# Deploy to production
vercel --prodTip: Generate NEXTAUTH_SECRET with 'openssl rand -base64 32' — never reuse secrets across environments.
Tip: Set up separate Stripe webhook endpoints for preview and production deployments.
Tip: Use Vercel's built-in Postgres (powered by Neon) for zero-config database provisioning.
Next Steps
- →Add team invitations with email-based onboarding flows using Resend or SendGrid.
- →Implement usage-based billing by tracking API calls or storage per organization.
- →Add an admin panel with user impersonation for customer support.
- →Set up monitoring with Sentry for error tracking and Vercel Analytics for performance.