Every SaaS founder eventually faces the same dilemma: "Should I build from scratch or use an existing boilerplate?" After launching three SaaS products myself and helping 20+ clients, I've created a production‑ready boilerplate that balances speed, scalability, and maintainability.
In this guide, I'll walk you through the architecture of a modern SaaS boilerplate using:
Start by creating a Next.js project with TypeScript and Tailwind:
npx create-next-app@latest my-saas --typescript --tailwind --app
cd my-saas
npm install @prisma/client prisma next-auth @auth/prisma-adapter @stripe/stripe-js @stripe/react-stripe-js stripe axios
Initialize Prisma and create your schema.prisma:
// 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?
image String?
emailVerified DateTime?
accounts Account[]
sessions Session[]
subscriptions Subscription[]
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 Subscription {
id String @id @default(cuid())
userId String
stripeCustomerId String @unique
stripeSubscriptionId String @unique
stripePriceId String
status String // active, past_due, canceled, incomplete
currentPeriodStart DateTime
currentPeriodEnd DateTime
cancelAtPeriodEnd Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
}
Run migrations:
npx prisma migrate dev --name init
npx prisma generate
Set up NextAuth with Prisma adapter in app/api/auth/[...nextauth]/route.ts:
import NextAuth from "next-auth"
import GoogleProvider from "next-auth/providers/google"
import GitHubProvider from "next-auth/providers/github"
import EmailProvider from "next-auth/providers/email"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
export const authOptions = {
adapter: PrismaAdapter(prisma),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
EmailProvider({
server: process.env.EMAIL_SERVER,
from: process.env.EMAIL_FROM,
}),
],
callbacks: {
async session({ session, user }) {
session.user.id = user.id;
return session;
},
},
pages: {
signIn: "/auth/signin",
verifyRequest: "/auth/verify-request",
},
}
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }
First, create a product and price in your Stripe dashboard. Then add a subscription creation endpoint:
// app/api/stripe/create-checkout/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { getServerSession } from "next-auth";
import { authOptions } from "../auth/[...nextauth]/route";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user) {
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 }],
success_url: `${process.env.NEXTAUTH_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXTAUTH_URL}/pricing`,
customer_email: session.user.email,
metadata: {
userId: session.user.id,
},
});
return NextResponse.json({ sessionId: checkoutSession.id });
}
On the frontend, use @stripe/react-stripe-js to redirect to checkout.
Webhooks are critical for updating subscription statuses. Create an endpoint at 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!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch (err) {
return NextResponse.json({ error: `Webhook Error: ${err.message}` }, { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
await prisma.subscription.upsert({
where: { stripeSubscriptionId: subscription.id },
update: {
status: subscription.status,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
create: {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
status: subscription.status,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
userId: session.metadata.userId,
},
});
break;
}
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const subscription = event.data.object;
await prisma.subscription.update({
where: { stripeSubscriptionId: subscription.id },
data: {
status: subscription.status,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
break;
}
}
return NextResponse.json({ received: true });
}
Don't forget to run stripe listen --forward-to localhost:3000/api/stripe/webhook locally and add the signing secret to your env variables.
Create a middleware to check authentication and subscription status:
// middleware.ts
import { withAuth } from "next-auth/middleware";
import { NextResponse } from "next/server";
export default withAuth(
function middleware(req) {
const token = req.nextauth.token;
const isOnDashboard = req.nextUrl.pathname.startsWith("/dashboard");
if (isOnDashboard && (!token || token.subscriptionStatus !== "active")) {
return NextResponse.redirect(new URL("/pricing", req.url));
}
return NextResponse.next();
},
{
callbacks: {
authorized: ({ token }) => !!token,
},
}
);
export const config = { matcher: ["/dashboard/:path*"] };
Then create a server component that fetches the user's subscription:
// app/dashboard/page.tsx
import { getServerSession } from "next-auth";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await getServerSession();
if (!session?.user) redirect("/auth/signin");
const subscription = await prisma.subscription.findUnique({
where: { userId: session.user.id },
});
const isActive = subscription?.status === "active";
return (
<div>
<h1>Dashboard</h1>
{isActive ? (
<p>Your subscription is active until {new Date(subscription.currentPeriodEnd).toDateString()}</p>
) : (
<p>Please upgrade to access premium features.</p>
)}
</div>
);
}
Allow users to manage their subscription, update payment methods, and cancel without leaving your app. Create an endpoint:
// app/api/stripe/create-portal/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { getServerSession } from "next-auth";
import { prisma } from "@/lib/prisma";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST() {
const session = await getServerSession();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const subscription = await prisma.subscription.findUnique({
where: { userId: session.user.id },
});
if (!subscription) return NextResponse.json({ error: "No subscription found" }, { status: 404 });
const portalSession = await stripe.billingPortal.sessions.create({
customer: subscription.stripeCustomerId,
return_url: `${process.env.NEXTAUTH_URL}/dashboard`,
});
return NextResponse.json({ url: portalSession.url });
}
Deploy on Vercel – it's a one‑click integration with GitHub. Set these environment variables:
DATABASE_URL (PostgreSQL from Supabase, Neon, or Railway)NEXTAUTH_SECRET (generate with openssl rand -base64 32)NEXTAUTH_URL = your production URLGOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETSTRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYAfter deploying, add your production webhook endpoint (https://yourdomain.com/api/stripe/webhook) in Stripe dashboard.
Don't ignore SEO for your marketing pages. Use metadata export in Next.js App Router:
// app/pricing/page.tsx
export const metadata = {
title: "Pricing – My SaaS",
description: "Choose the perfect plan for your business. Start with a 14‑day free trial.",
openGraph: {
title: "Pricing – My SaaS",
description: "Simple, transparent pricing.",
images: ["https://mysaas.com/og-pricing.jpg"],
},
};
Here are mistakes I made so you don't have to:
stripeSubscriptionId before creating/updating.trial_period_days on checkout sessions.Once your boilerplate is live:
You now have a blueprint for a production‑ready SaaS boilerplate. It's not theoretical – this exact architecture powers several successful products generating over ₹2Cr in client revenue. Focus on your unique value proposition; the technical foundation is ready.
Have questions or want help building your SaaS? Contact me – I'm taking only 2 custom projects per month.
Follow me on Twitter/X for more full‑stack insights.