Building SaaS Products with Next.js 16 and Bun in 2025
A practical guide to bootstrapping a production-ready SaaS using Next.js App Router, Bun as the runtime and package manager, Prisma, and Stripe.

Bun 1.x is now stable and fast enough to run Next.js in production. Paired with Next.js 16's App Router and the new partial prerendering model, it's the most productive stack I've shipped SaaS products on. Here's the full setup I use on every new project.
Why Bun Over Node?
Bun replaces Node, npm, and webpack simultaneously. In practice:
bun installtakes ~300ms on a fresh clone vs 40s+ for npm- Native TypeScript execution — no ts-node or build step for scripts
- Built-in test runner (
bun test) compatible with Jest matchers - Drop-in compatible with most npm packages
The only caveat: a handful of native Node modules still don't work. For SaaS apps (Prisma, Stripe, Next.js), everything works fine.
Project Bootstrap
Create the project and install dependencies:
bun create next-app my-saas --typescript --tailwind --app
cd my-saas
# Core deps
bun add prisma @prisma/client stripe @stripe/stripe-js
bun add next-auth @auth/prisma-adapter
bun add resend # transactional email
# Dev
bun add -d @types/nodeWorkspace Setup for Monorepos
If you're building a monorepo (API + web + shared types), Bun's workspace support is excellent:
// package.json (root)
{
"name": "my-saas",
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"dev": "bun run --cwd apps/web dev",
"build": "bun run --cwd apps/web build",
"db:push": "bun run --cwd apps/web prisma db push"
}
}Database Schema with Prisma
Define your schema:
// 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?
plan Plan @default(FREE)
stripeId String? @unique
createdAt DateTime @default(now())
subscriptions Subscription[]
}
enum Plan {
FREE
PRO
ENTERPRISE
}
model Subscription {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
stripeSubId String @unique
stripePriceId String
status String
currentPeriodEnd DateTime
}Push to your dev DB:
bun prisma db push
bun prisma generateAuthentication with NextAuth v5
NextAuth v5 has excellent App Router support:
// lib/auth.ts
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import Google from "next-auth/providers/google";
import { prisma } from "./prisma";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
callbacks: {
session({ session, user }) {
session.user.id = user.id;
return session;
},
},
});Add the catch-all route handler:
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;Stripe Subscription Flow
The three Stripe webhooks you actually need:
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { prisma } from "@/lib/prisma";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
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,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
switch (event.type) {
case "customer.subscription.created":
case "customer.subscription.updated": {
const sub = event.data.object as Stripe.Subscription;
await prisma.subscription.upsert({
where: { stripeSubId: sub.id },
create: {
stripeSubId: sub.id,
stripePriceId: sub.items.data[0].price.id,
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
user: { connect: { stripeId: sub.customer as string } },
},
update: {
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
},
});
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await prisma.subscription.update({
where: { stripeSubId: sub.id },
data: { status: "canceled" },
});
break;
}
}
return new Response("ok");
}Protecting Routes with Middleware
Protect your dashboard routes at the edge:
// middleware.ts (or proxy.ts in Next.js 16)
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));
}
});
export const config = {
matcher: ["/dashboard/:path*"],
};Deployment
Bun works on Vercel with zero config — just set the install command:
Install Command: bun install
Build Command: bun run buildFor Fly.io or Railway with a custom Dockerfile:
FROM oven/bun:1 AS base
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
EXPOSE 3000
CMD ["bun", "start"]Closing Thoughts
This stack — Next.js 16 + Bun + Prisma + Stripe + NextAuth — gets a SaaS from zero to production in a weekend. Bun's speed removes the "waiting for installs" friction from development, and Next.js App Router's server components make data fetching feel effortless.
The one thing I'd add: set up error monitoring (Sentry or Axiom) on day one. You want visibility before users report bugs, not after.