Stripe Payments in Next.js 15 — The Setup I Use for Every SaaS Project

1 1 17
calendar_todayschedule5 min read

Every SaaS project eventually needs the same thing, a way to charge people. Stripe handles the actual payment processing, but wiring it into Next.js correctly, especially getting webhooks right, is where most of the real work sits.

Here is the setup I use for checkout and subscriptions in every SaaS project.


1. The Stripe Client

// lib/stripe.ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
  apiVersion: '2024-11-20.acacia',
  typescript: true,
});

One client, imported wherever needed. Nothing fancy, but keeping it in one file means the API version and config only need to be set once.


2. Creating a Checkout Session

The checkout flow starts with a Server Action that creates a Stripe Checkout Session and redirects the user to Stripe's hosted payment page.

// actions/checkout.ts
'use server';
import { stripe } from '@/lib/stripe';
import { getSession } from '@/lib/auth';
import { redirect } from 'next/navigation';

export async function createCheckoutSession(priceId: string) {
  const session = await getSession();
  if (!session) redirect('/login');

  const checkoutSession = await stripe.checkout.sessions.create({
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [{ price: priceId, quantity: 1 }],
    customer_email: session.email,
    success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing?canceled=true`,
    metadata: {
      userId: session.userId,
    },
  });

  redirect(checkoutSession.url as string);
}

The metadata.userId here matters more than it looks. Stripe has no idea who your user is beyond what you attach in metadata, and the webhook handler needs this to know which account to update once payment completes.

// components/PricingCard.tsx
import { createCheckoutSession } from '@/actions/checkout';

export function PricingCard({ priceId, name, price }: { priceId: string; name: string; price: string }) {
  return (
    <form action={createCheckoutSession.bind(null, priceId)}>
      <h3>{name}</h3>
      <p>{price}/month</p>
      <button type="submit">Subscribe</button>
    </form>
  );
}

3. Why Webhooks Are Not Optional

This is the part people try to skip, and it always causes problems. It is tempting to just update the database directly on the success_url page after redirect. Do not do this. The redirect happens in the user's browser, which means it can fail to load, get closed early, or never fire at all, and your database never finds out the payment actually succeeded.

Webhooks are Stripe calling your server directly, independent of whether the user's browser is even still open. This is the only reliable source of truth for what actually happened with a payment.

// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe';
import { connectDB } from '@/lib/db';
import User from '@/models/User';
import { headers } from 'next/headers';
import Stripe from 'stripe';

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET as string;

export async function POST(request: Request) {
  const body = await request.text();
  const headersList = await headers();
  const signature = headersList.get('stripe-signature') as string;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    return new Response('Invalid signature', { status: 400 });
  }

  await connectDB();

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session;
      const userId = session.metadata?.userId;

      await User.findByIdAndUpdate(userId, {
        stripeCustomerId: session.customer as string,
        stripeSubscriptionId: session.subscription as string,
        subscriptionStatus: 'active',
      });
      break;
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;
      await User.findOneAndUpdate(
        { stripeSubscriptionId: subscription.id },
        { subscriptionStatus: 'canceled' }
      );
      break;
    }

    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription;
      await User.findOneAndUpdate(
        { stripeSubscriptionId: subscription.id },
        { subscriptionStatus: subscription.status }
      );
      break;
    }
  }

  return new Response('OK', { status: 200 });
}

stripe.webhooks.constructEvent verifies the request actually came from Stripe using the signature header, not from anyone who happened to guess your webhook URL. Skipping this check means anyone could send a fake checkout.session.completed payload and grant themselves a subscription for free.


4. Route Handlers Need Raw Body, Not Parsed JSON

This trips people up specifically with Next.js. Signature verification needs the exact raw request body, byte for byte, not a parsed and re-serialized version of it.

export async function POST(request: Request) {
  const body = await request.text(); // raw text, not request.json()
  // ...
}

Using request.json() here silently breaks signature verification, since JSON parsing and re-stringifying can change whitespace and key order in ways that make the signature no longer match.


5. Registering the Webhook Locally and in Production

For local development, the Stripe CLI forwards events to your local server:

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

This prints a webhook signing secret specific to your CLI session, used as STRIPE_WEBHOOK_SECRET locally. In production, the webhook endpoint gets registered in the Stripe dashboard pointing at your real domain, with its own separate signing secret, different from the local one.


6. Checking Subscription Status Elsewhere in the App

Once the webhook keeps subscriptionStatus in sync, the rest of the app just reads it, no direct Stripe API calls needed on every page load.

// lib/queries/users.ts
export async function requireActiveSubscription() {
  const session = await getSession();
  if (!session) redirect('/login');

  await connectDB();
  const user = await User.findById(session.userId).lean();

  if (user?.subscriptionStatus !== 'active') {
    redirect('/pricing');
  }

  return user;
}
// app/(dashboard)/layout.tsx
import { requireActiveSubscription } from '@/lib/queries/users';

export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
  await requireActiveSubscription();
  return <div>{children}</div>;
}

The database is the source of truth for whether a user has access, kept accurate by webhooks. The app never needs to ask Stripe directly just to render a page.


7. Handling Failed Payments

Subscriptions do not just cancel cleanly, cards expire, charges get declined. Stripe retries automatically, but your app needs to know when a subscription enters a past-due state:

case 'invoice.payment_failed': {
  const invoice = event.data.object as Stripe.Invoice;
  await User.findOneAndUpdate(
    { stripeCustomerId: invoice.customer as string },
    { subscriptionStatus: 'past_due' }
  );
  break;
}

Whether past_due still grants access or restricts it is a product decision, not a technical one, but the webhook is what makes that state visible to your app in the first place.


Summary

Piece Handles
Checkout Session Starting a subscription, redirecting to Stripe's hosted page
Webhook endpoint The only reliable source of truth for payment events
Signature verification Confirming events actually came from Stripe
Raw request body Required for signature verification to succeed
Database subscriptionStatus What the rest of the app actually checks, kept in sync by webhooks
invoice.payment_failed Catching failed renewals, not just initial signups

The core lesson, learned the hard way on an early project: never trust the browser redirect as confirmation that a payment succeeded. The webhook is the only event that cannot be skipped, closed early, or spoofed without a valid signature, which makes it the only place worth writing real subscription logic.

I use this exact checkout and webhook setup across every SaaS project I build with subscription billing.

Get the templates: https://pixelanas.gumroad.com

Have you had a webhook edge case bite you in production? Drop it below 👇


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelski - Apr 23

Complete MDX Blog Setup with next-mdx-remote and Next.js 16 (2026 Beginner Guide)

muhammadali - Jul 30

Internationalization in Next.js 15 — The Setup I Use for Multi-Language Apps

Anas Sheikh - Sep 3

Real-Time Features in Next.js 15 — The Pattern I Use for Live Updates

Anas Sheikh - Aug 29

Merancang Backend Bisnis ISP: API Pelanggan, Paket Internet, Invoice, dan Tiket Support

Masbadar - Mar 13
chevron_left
674 Points19 Badges
Pakistanpixelanas.com
20Posts
6Comments
4Connections
Full-stack developer building premium Next.js templates & web apps. Specializing in GSAP animations,... Show more

Related Jobs

View all jobs →

Commenters (This Week)

1 comment
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!