So, I've built a few SaaS products over the last year, and the part that always takes the longest isn't the product, it's the plumbing. Auth, billing, database, dashboard, the stuff every SaaS needs before you write a single line of business logic.
For anyone who cares; I stripped my production stack down to the essentials and turned it into a repeatable setup. Here's exactly what it looks like and how long each piece takes.
The stack
- Next.js 14 with App Router + TypeScript
- NextAuth v4 , email/password + Google OAuth
- Stripe , subscription checkout + customer portal
- Prisma , type-safe ORM with PostgreSQL
- Tailwind CSS , utility-first styling
Minutes 0-30: Auth
Email/password auth with NextAuth takes about 20 minutes if you've done it before. Google OAuth adds another 10. The key is setting up the Prisma adapter so user sessions persist across restarts.
// app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import CredentialsProvider from "next-auth/providers/credentials";
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! }),
CredentialsProvider({ /* email + password */ }),
],
};
Minutes 30-60: Database + Schema
Prisma makes this fast. Define your models in schema.prisma, run npx prisma db push, and you have a working database.
model User {
id String @id @default(cuid())
email String @unique
name String?
createdAt DateTime @default(now())
accounts Account[]
sessions Session[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
// ... standard NextAuth fields
}
Minutes 60-90: Stripe Billing
The Stripe integration has two parts: the checkout session (when a user upgrades) and the customer portal (when they manage their subscription).
// app/api/stripe/checkout/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { userId, priceId } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
metadata: { userId },
});
return Response.json({ url: session.url });
}
The webhook handler listens for checkout.session.completed and updates the user's subscription status in Prisma. That's it , no custom billing logic needed.
Minutes 90-120: Dashboard + Landing Page
The last 30 minutes is UI. A responsive landing page, a pricing table that calls the checkout endpoint, and a dashboard that shows subscription status. Tailwind makes this fast, I use a utility-first approach and build components inline.
// app/dashboard/page.tsx
export default async function Dashboard() {
const session = await getServerSession(authOptions);
const user = await prisma.user.findUnique({ where: { email: session.user.email } });
const isPro = user?.subscriptionStatus === "active";
return (
<div className="max-w-4xl mx-auto p-8">
<h1 className="text-2xl font-bold">Welcome, {user.name}</h1>
{isPro ? <ProFeatures /> : <UpgradePrompt />}
</div>
);
}
The result
Two hours from npx create-next-app to a working SaaS with auth, billing, and a dashboard. The boilerplate isn't glamorous, but it's the foundation every product sits on.
I use this exact stack for my invoicing SaaS (invokepay.com) and it's been running in production for months without issues.
*I packaged this exact stack as a starter kit: [Next.js SaaS Starter] on Gumroad if anyone is interested.
Top comments (0)