Build a SaaS Starter Kit with Next.js 15, Stripe Subscriptions, and Auth.js v5

Noqta TeamAI Bot
By Noqta Team & AI Bot ·

Loading the Text to Speech Audio Player...

Build your own SaaS billing system from scratch. This tutorial walks you through creating a complete subscription-based application with Next.js 15 Server Actions, Stripe Checkout, customer portals, and Auth.js v5 — everything you need to launch a real SaaS product.

What You Will Learn

By the end of this tutorial, you will:

  • Set up Auth.js v5 with GitHub and Google OAuth providers
  • Integrate Stripe Checkout for subscription billing
  • Build a pricing page with multiple plan tiers
  • Handle Stripe webhooks to sync subscription state
  • Create protected routes with middleware and session checks
  • Implement a customer portal for managing subscriptions
  • Store user and subscription data with Drizzle ORM and PostgreSQL

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed
  • A Stripe account (free to create at stripe.com)
  • A GitHub OAuth app or Google Cloud project for authentication
  • PostgreSQL running locally (or a hosted instance like Neon or Supabase)
  • Basic familiarity with Next.js and TypeScript

Architecture Overview

Here is the high-level architecture of what we are building:

┌─────────────────────────────────────────────┐
│                  Next.js 15                  │
│                                              │
│  ┌──────────┐  ┌──────────┐  ┌───────────┐  │
│  │ Auth.js  │  │  Stripe  │  │  Drizzle  │  │
│  │   v5     │  │ Checkout │  │    ORM    │  │
│  └────┬─────┘  └────┬─────┘  └─────┬─────┘  │
│       │              │              │         │
│  ┌────▼──────────────▼──────────────▼─────┐  │
│  │           Server Actions               │  │
│  └────────────────────────────────────────┘  │
└──────────────────┬───────────────────────────┘
                   │
         ┌─────────▼─────────┐
         │    PostgreSQL      │
         │  (Users, Subs)     │
         └───────────────────┘

Step 1: Create the Next.js Project

Start by scaffolding a new Next.js 15 application with TypeScript and the App Router:

npx create-next-app@latest saas-starter --typescript --tailwind --eslint --app --src-dir
cd saas-starter

Install the dependencies we need:

npm install next-auth@beta @auth/drizzle-adapter drizzle-orm @neondatabase/serverless stripe
npm install -D drizzle-kit dotenv

Create your .env.local file with the required environment variables:

# Database
DATABASE_URL="postgresql://user:password@localhost:5432/saas_starter"
 
# Auth.js
AUTH_SECRET="run-npx-auth-secret-to-generate"
AUTH_GITHUB_ID="your-github-client-id"
AUTH_GITHUB_SECRET="your-github-client-secret"
AUTH_GOOGLE_ID="your-google-client-id"
AUTH_GOOGLE_SECRET="your-google-client-secret"
 
# Stripe
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_PUBLISHABLE_KEY="pk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
 
# App
NEXT_PUBLIC_APP_URL="http://localhost:3000"

Generate your AUTH_SECRET by running npx auth secret in your terminal. Never commit this value to version control.

Step 2: Set Up the Database Schema with Drizzle ORM

Create the database schema that handles both Auth.js sessions and Stripe subscriptions.

Create src/db/schema.ts:

import {
  pgTable,
  text,
  timestamp,
  integer,
  primaryKey,
  boolean,
} from "drizzle-orm/pg-core";
import type { AdapterAccountType } from "next-auth/adapters";
 
// Auth.js required tables
export const users = pgTable("users", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  name: text("name"),
  email: text("email").unique(),
  emailVerified: timestamp("emailVerified", { mode: "date" }),
  image: text("image"),
  stripeCustomerId: text("stripe_customer_id").unique(),
  stripePriceId: text("stripe_price_id"),
  stripeSubscriptionId: text("stripe_subscription_id").unique(),
  stripeSubscriptionStatus: text("stripe_subscription_status"),
  createdAt: timestamp("created_at", { mode: "date" }).defaultNow(),
});
 
export const accounts = pgTable(
  "accounts",
  {
    userId: text("userId")
      .notNull()
      .references(() => users.id, { onDelete: "cascade" }),
    type: text("type").$type<AdapterAccountType>().notNull(),
    provider: text("provider").notNull(),
    providerAccountId: text("providerAccountId").notNull(),
    refresh_token: text("refresh_token"),
    access_token: text("access_token"),
    expires_at: integer("expires_at"),
    token_type: text("token_type"),
    scope: text("scope"),
    id_token: text("id_token"),
    session_state: text("session_state"),
  },
  (account) => [
    primaryKey({
      columns: [account.provider, account.providerAccountId],
    }),
  ]
);
 
export const sessions = pgTable("sessions", {
  sessionToken: text("sessionToken").primaryKey(),
  userId: text("userId")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  expires: timestamp("expires", { mode: "date" }).notNull(),
});
 
export const verificationTokens = pgTable(
  "verificationTokens",
  {
    identifier: text("identifier").notNull(),
    token: text("token").notNull(),
    expires: timestamp("expires", { mode: "date" }).notNull(),
  },
  (verificationToken) => [
    primaryKey({
      columns: [verificationToken.identifier, verificationToken.token],
    }),
  ]
);

Create src/db/index.ts for the database connection:

import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
 
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });

Create drizzle.config.ts at the project root:

import { defineConfig } from "drizzle-kit";
import dotenv from "dotenv";
 
dotenv.config({ path: ".env.local" });
 
export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

Run the migration:

npx drizzle-kit push

Step 3: Configure Auth.js v5

Create src/auth.ts at the source root:

import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/db";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db),
  providers: [GitHub, Google],
  callbacks: {
    session({ session, user }) {
      session.user.id = user.id;
      return session;
    },
  },
  pages: {
    signIn: "/login",
  },
});

Create the API route at src/app/api/auth/[...nextauth]/route.ts:

import { handlers } from "@/auth";
 
export const { GET, POST } = handlers;

Create the login page at src/app/login/page.tsx:

import { signIn } from "@/auth";
 
export default function LoginPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="w-full max-w-sm space-y-6 rounded-xl border p-8 shadow-sm">
        <div className="text-center">
          <h1 className="text-2xl font-bold">Welcome back</h1>
          <p className="mt-2 text-sm text-gray-500">
            Sign in to your account to continue
          </p>
        </div>
 
        <form
          action={async () => {
            "use server";
            await signIn("github", { redirectTo: "/dashboard" });
          }}
        >
          <button
            type="submit"
            className="flex w-full items-center justify-center gap-2 rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800"
          >
            Continue with GitHub
          </button>
        </form>
 
        <form
          action={async () => {
            "use server";
            await signIn("google", { redirectTo: "/dashboard" });
          }}
        >
          <button
            type="submit"
            className="flex w-full items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium hover:bg-gray-50"
          >
            Continue with Google
          </button>
        </form>
      </div>
    </div>
  );
}

Step 4: Protect Routes with Middleware

Create src/middleware.ts to protect authenticated routes:

import { auth } from "@/auth";
 
export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isOnDashboard = req.nextUrl.pathname.startsWith("/dashboard");
  const isOnLogin = req.nextUrl.pathname === "/login";
 
  if (isOnDashboard && !isLoggedIn) {
    return Response.redirect(new URL("/login", req.nextUrl));
  }
 
  if (isOnLogin && isLoggedIn) {
    return Response.redirect(new URL("/dashboard", req.nextUrl));
  }
});
 
export const config = {
  matcher: ["/dashboard/:path*", "/login"],
};

Step 5: Set Up Stripe Products and Prices

Before writing code, create your products in the Stripe Dashboard or use the Stripe CLI:

# Install Stripe CLI
brew install stripe/stripe-cli/stripe
 
# Login to your Stripe account
stripe login
 
# Create products and prices
stripe products create --name="Starter" --description="For individuals and small projects"
stripe prices create --product=prod_xxx --unit-amount=900 --currency=usd --recurring[interval]=month
 
stripe products create --name="Pro" --description="For growing teams and businesses"
stripe prices create --product=prod_yyy --unit-amount=2900 --currency=usd --recurring[interval]=month
 
stripe products create --name="Enterprise" --description="For large organizations"
stripe prices create --product=prod_zzz --unit-amount=9900 --currency=usd --recurring[interval]=month

Now create src/lib/stripe.ts:

import Stripe from "stripe";
 
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-12-18.acacia",
  typescript: true,
});
 
export const PLANS = [
  {
    name: "Starter",
    description: "For individuals and small projects",
    price: "$9",
    priceId: process.env.STRIPE_STARTER_PRICE_ID!,
    features: [
      "Up to 3 projects",
      "Basic analytics",
      "Email support",
      "1 GB storage",
    ],
  },
  {
    name: "Pro",
    description: "For growing teams and businesses",
    price: "$29",
    priceId: process.env.STRIPE_PRO_PRICE_ID!,
    popular: true,
    features: [
      "Unlimited projects",
      "Advanced analytics",
      "Priority support",
      "10 GB storage",
      "Team collaboration",
      "Custom integrations",
    ],
  },
  {
    name: "Enterprise",
    description: "For large organizations",
    price: "$99",
    priceId: process.env.STRIPE_ENTERPRISE_PRICE_ID!,
    features: [
      "Everything in Pro",
      "Unlimited storage",
      "Dedicated support",
      "SSO & SAML",
      "Custom contracts",
      "SLA guarantees",
    ],
  },
] as const;

Step 6: Build the Pricing Page

Create src/app/pricing/page.tsx:

import { auth } from "@/auth";
import { PLANS } from "@/lib/stripe";
import { CheckoutButton } from "./checkout-button";
 
export default async function PricingPage() {
  const session = await auth();
 
  return (
    <div className="mx-auto max-w-5xl px-4 py-16">
      <div className="text-center">
        <h1 className="text-4xl font-bold tracking-tight">
          Simple, transparent pricing
        </h1>
        <p className="mt-4 text-lg text-gray-500">
          Choose the plan that fits your needs. Upgrade or downgrade at any
          time.
        </p>
      </div>
 
      <div className="mt-16 grid gap-8 md:grid-cols-3">
        {PLANS.map((plan) => (
          <div
            key={plan.name}
            className={`relative rounded-2xl border p-8 shadow-sm ${
              plan.popular
                ? "border-blue-600 ring-2 ring-blue-600"
                : "border-gray-200"
            }`}
          >
            {plan.popular && (
              <span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-blue-600 px-3 py-1 text-xs font-semibold text-white">
                Most Popular
              </span>
            )}
 
            <h3 className="text-lg font-semibold">{plan.name}</h3>
            <p className="mt-1 text-sm text-gray-500">{plan.description}</p>
 
            <p className="mt-6">
              <span className="text-4xl font-bold">{plan.price}</span>
              <span className="text-gray-500">/month</span>
            </p>
 
            <CheckoutButton
              priceId={plan.priceId}
              isLoggedIn={!!session}
              popular={plan.popular}
            />
 
            <ul className="mt-8 space-y-3">
              {plan.features.map((feature) => (
                <li key={feature} className="flex items-center gap-2 text-sm">
                  <svg
                    className="h-4 w-4 text-green-500"
                    fill="none"
                    viewBox="0 0 24 24"
                    strokeWidth={2}
                    stroke="currentColor"
                  >
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      d="M4.5 12.75l6 6 9-13.5"
                    />
                  </svg>
                  {feature}
                </li>
              ))}
            </ul>
          </div>
        ))}
      </div>
    </div>
  );
}

Create the checkout button as a client component at src/app/pricing/checkout-button.tsx:

"use client";
 
import { useTransition } from "react";
import { createCheckoutSession } from "./actions";
 
interface CheckoutButtonProps {
  priceId: string;
  isLoggedIn: boolean;
  popular?: boolean;
}
 
export function CheckoutButton({
  priceId,
  isLoggedIn,
  popular,
}: CheckoutButtonProps) {
  const [isPending, startTransition] = useTransition();
 
  const handleCheckout = () => {
    startTransition(async () => {
      const { url } = await createCheckoutSession(priceId);
      if (url) window.location.href = url;
    });
  };
 
  return (
    <button
      onClick={handleCheckout}
      disabled={isPending}
      className={`mt-6 w-full rounded-lg px-4 py-2.5 text-sm font-semibold ${
        popular
          ? "bg-blue-600 text-white hover:bg-blue-700"
          : "bg-gray-900 text-white hover:bg-gray-800"
      } disabled:opacity-50`}
    >
      {isPending ? "Loading..." : isLoggedIn ? "Subscribe" : "Get Started"}
    </button>
  );
}

Step 7: Create Checkout Server Actions

Create src/app/pricing/actions.ts:

"use server";
 
import { auth } from "@/auth";
import { stripe } from "@/lib/stripe";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
 
export async function createCheckoutSession(priceId: string) {
  const session = await auth();
 
  if (!session?.user?.id) {
    redirect("/login");
  }
 
  // Get or create Stripe customer
  const [user] = await db
    .select()
    .from(users)
    .where(eq(users.id, session.user.id));
 
  let customerId = user.stripeCustomerId;
 
  if (!customerId) {
    const customer = await stripe.customers.create({
      email: user.email!,
      name: user.name ?? undefined,
      metadata: { userId: user.id },
    });
 
    await db
      .update(users)
      .set({ stripeCustomerId: customer.id })
      .where(eq(users.id, user.id));
 
    customerId = customer.id;
  }
 
  // Create checkout session
  const checkoutSession = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: "subscription",
    payment_method_types: ["card"],
    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?canceled=true`,
    subscription_data: {
      metadata: { userId: session.user.id },
    },
  });
 
  return { url: checkoutSession.url };
}
 
export async function createCustomerPortalSession() {
  const session = await auth();
 
  if (!session?.user?.id) {
    redirect("/login");
  }
 
  const [user] = await db
    .select()
    .from(users)
    .where(eq(users.id, session.user.id));
 
  if (!user.stripeCustomerId) {
    redirect("/pricing");
  }
 
  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
  });
 
  return { url: portalSession.url };
}

Step 8: Handle Stripe Webhooks

This is the most critical part. Stripe webhooks keep your database in sync with subscription changes.

Create src/app/api/webhooks/stripe/route.ts:

import { headers } from "next/headers";
import { stripe } from "@/lib/stripe";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import type Stripe from "stripe";
 
export async function POST(req: Request) {
  const body = await req.text();
  const headersList = await headers();
  const signature = headersList.get("stripe-signature")!;
 
  let event: Stripe.Event;
 
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error("Webhook signature verification failed:", err);
    return new Response("Webhook Error", { status: 400 });
  }
 
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      const subscription = await stripe.subscriptions.retrieve(
        session.subscription as string
      );
 
      const userId = subscription.metadata.userId;
 
      await db
        .update(users)
        .set({
          stripeSubscriptionId: subscription.id,
          stripePriceId: subscription.items.data[0].price.id,
          stripeSubscriptionStatus: subscription.status,
        })
        .where(eq(users.id, userId));
 
      break;
    }
 
    case "customer.subscription.updated": {
      const subscription = event.data.object as Stripe.Subscription;
 
      await db
        .update(users)
        .set({
          stripePriceId: subscription.items.data[0].price.id,
          stripeSubscriptionStatus: subscription.status,
        })
        .where(eq(users.stripeSubscriptionId, subscription.id));
 
      break;
    }
 
    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
 
      await db
        .update(users)
        .set({
          stripePriceId: null,
          stripeSubscriptionId: null,
          stripeSubscriptionStatus: "canceled",
        })
        .where(eq(users.stripeSubscriptionId, subscription.id));
 
      break;
    }
  }
 
  return new Response("OK", { status: 200 });
}

To test webhooks locally, use the Stripe CLI:

stripe listen --forward-to localhost:3000/api/webhooks/stripe

Keep the Stripe CLI running in a separate terminal while developing. It will forward webhook events to your local server and give you a webhook signing secret to use in .env.local.

Step 9: Build the Dashboard

Create the protected dashboard at src/app/dashboard/page.tsx:

import { auth, signOut } from "@/auth";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { PLANS } from "@/lib/stripe";
import { ManageSubscriptionButton } from "./manage-subscription-button";
 
export default async function DashboardPage() {
  const session = await auth();
  if (!session?.user?.id) redirect("/login");
 
  const [user] = await db
    .select()
    .from(users)
    .where(eq(users.id, session.user.id));
 
  const currentPlan = PLANS.find((p) => p.priceId === user.stripePriceId);
  const isActive = user.stripeSubscriptionStatus === "active";
 
  return (
    <div className="mx-auto max-w-4xl px-4 py-16">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold">Dashboard</h1>
          <p className="text-gray-500">Welcome back, {session.user.name}</p>
        </div>
        <form
          action={async () => {
            "use server";
            await signOut({ redirectTo: "/" });
          }}
        >
          <button
            type="submit"
            className="rounded-lg border px-4 py-2 text-sm hover:bg-gray-50"
          >
            Sign Out
          </button>
        </form>
      </div>
 
      <div className="mt-8 rounded-xl border p-6">
        <h2 className="text-lg font-semibold">Subscription</h2>
 
        {isActive && currentPlan ? (
          <div className="mt-4 space-y-4">
            <div className="flex items-center gap-3">
              <span className="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">
                Active
              </span>
              <span className="font-medium">{currentPlan.name} Plan</span>
              <span className="text-gray-500">
                {currentPlan.price}/month
              </span>
            </div>
            <ManageSubscriptionButton />
          </div>
        ) : (
          <div className="mt-4 space-y-4">
            <p className="text-gray-500">
              You are currently on the free tier.
            </p>
            <a
              href="/pricing"
              className="inline-block rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700"
            >
              Upgrade Now
            </a>
          </div>
        )}
      </div>
 
      <div className="mt-8 rounded-xl border p-6">
        <h2 className="text-lg font-semibold">Account</h2>
        <div className="mt-4 space-y-2 text-sm">
          <p>
            <span className="text-gray-500">Email:</span> {user.email}
          </p>
          <p>
            <span className="text-gray-500">Member since:</span>{" "}
            {user.createdAt?.toLocaleDateString()}
          </p>
        </div>
      </div>
    </div>
  );
}

Create src/app/dashboard/manage-subscription-button.tsx:

"use client";
 
import { useTransition } from "react";
import { createCustomerPortalSession } from "../pricing/actions";
 
export function ManageSubscriptionButton() {
  const [isPending, startTransition] = useTransition();
 
  return (
    <button
      onClick={() => {
        startTransition(async () => {
          const { url } = await createCustomerPortalSession();
          if (url) window.location.href = url;
        });
      }}
      disabled={isPending}
      className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 disabled:opacity-50"
    >
      {isPending ? "Loading..." : "Manage Subscription"}
    </button>
  );
}

Step 10: Add Subscription Checks to Your API

Create a reusable helper at src/lib/subscription.ts to gate features behind plans:

import { auth } from "@/auth";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
 
export async function getUserSubscription() {
  const session = await auth();
  if (!session?.user?.id) return null;
 
  const [user] = await db
    .select({
      stripePriceId: users.stripePriceId,
      stripeSubscriptionStatus: users.stripeSubscriptionStatus,
    })
    .from(users)
    .where(eq(users.id, session.user.id));
 
  return {
    isActive: user?.stripeSubscriptionStatus === "active",
    priceId: user?.stripePriceId,
  };
}
 
export async function requireSubscription() {
  const sub = await getUserSubscription();
  if (!sub?.isActive) {
    throw new Error("Active subscription required");
  }
  return sub;
}

Use this in any Server Action or API route:

"use server";
 
import { requireSubscription } from "@/lib/subscription";
 
export async function premiumFeatureAction() {
  await requireSubscription(); // Throws if not subscribed
 
  // Your premium logic here
}

Testing Your Implementation

1. Test the authentication flow

  1. Start the development server: npm run dev
  2. Visit http://localhost:3000/login
  3. Sign in with GitHub or Google
  4. Verify you are redirected to /dashboard

2. Test the subscription flow

  1. Visit /pricing and click a plan
  2. Use Stripe test card: 4242 4242 4242 4242 (any future expiry, any CVC)
  3. Complete the checkout and verify the dashboard shows your active plan

3. Test webhooks locally

# Terminal 1: Start Next.js
npm run dev
 
# Terminal 2: Forward Stripe events
stripe listen --forward-to localhost:3000/api/webhooks/stripe
 
# Terminal 3: Trigger a test event
stripe trigger checkout.session.completed

4. Test the customer portal

Click "Manage Subscription" on the dashboard to verify the Stripe Customer Portal opens and allows plan changes and cancellations.

Troubleshooting

"No matching state found" error during OAuth

This usually means your AUTH_SECRET is missing or has changed. Regenerate it with npx auth secret.

Webhooks not reaching your local server

Make sure the Stripe CLI is running with stripe listen. Check that the STRIPE_WEBHOOK_SECRET in .env.local matches the secret the CLI outputs.

Subscription status not updating

Check the webhook logs in the Stripe Dashboard under Developers > Webhooks. Ensure the webhook endpoint URL is correct and the signing secret matches.

Database migration errors

If you change the schema, run npx drizzle-kit push again to sync. For production, use npx drizzle-kit generate followed by npx drizzle-kit migrate.

Going to Production

When deploying to production, remember to:

  1. Set up the Stripe webhook endpoint in the Stripe Dashboard pointing to https://your-domain.com/api/webhooks/stripe
  2. Switch to live Stripe keys — replace sk_test_ and pk_test_ with your live keys
  3. Configure OAuth redirect URLs in GitHub/Google for your production domain
  4. Set all environment variables in your hosting platform (Vercel, Railway, etc.)
  5. Enable the Stripe Customer Portal in your Stripe Dashboard under Settings > Billing > Customer Portal

Next Steps

Now that you have a working SaaS billing system, here are some ideas for extending it:

  • Add email notifications using Resend or SendGrid for subscription events
  • Implement usage-based billing with Stripe metered billing for API-based products
  • Add team management with organization support and role-based access
  • Build an admin dashboard to view revenue metrics and customer data
  • Add a free trial period using Stripe's trial_period_days parameter

Conclusion

You have built a complete SaaS starter kit with Next.js 15, Auth.js v5, and Stripe. This foundation gives you OAuth authentication, subscription billing with multiple tiers, webhook-driven state management, and a customer self-service portal. From here, you can focus on building the actual features of your SaaS product, knowing the billing infrastructure is solid.

The patterns used in this tutorial — Server Actions for mutations, middleware for route protection, and webhooks for state synchronization — represent the modern Next.js approach that scales well from side projects to production applications.


Want to read more tutorials? Check out our latest tutorial on End-to-End Testing with Playwright and Next.js: From Zero to CI Pipeline.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles