M·H·ABlog
Next.js·11 min

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.

Muhammad Hamza Aftab
Muhammad Hamza Aftab
Next.jsBunSaaSTypeScript
Ad · 336×280 Rectangle

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 install takes ~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:

bash
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/node

Workspace Setup for Monorepos

If you're building a monorepo (API + web + shared types), Bun's workspace support is excellent:

json
// 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
// 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:

bash
bun prisma db push
bun prisma generate

Authentication with NextAuth v5

NextAuth v5 has excellent App Router support:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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 build

For Fly.io or Railway with a custom Dockerfile:

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.

Ad · 728×90 Leaderboard