DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Next.js 14 + Stripe: The Complete Integration Guide (App Router)

Next.js 14 + Stripe: The Complete Integration Guide (App Router)

Stripe integration with Next.js App Router trips up more developers than it should. The docs cover the pieces — but not how they fit together in a production app.

This is the pattern I've settled on after wiring up Stripe in multiple AI SaaS products.


Architecture Overview

Three moving parts:

  1. Stripe Checkout — redirect users to Stripe's hosted payment page
  2. Webhooks — Stripe calls your API to confirm payment completed
  3. Access control — check payment status before showing protected content

The common mistake: trying to grant access on the Checkout success redirect. Don't. The redirect can be spoofed. Webhooks cannot.


1. Install and Configure

npm install stripe @stripe/stripe-js
Enter fullscreen mode Exit fullscreen mode

.env.local:

STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
Enter fullscreen mode Exit fullscreen mode

lib/stripe.ts:

import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-04-10",
  typescript: true,
});
Enter fullscreen mode Exit fullscreen mode

2. Create a Checkout Session

app/api/checkout/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { auth } from "@/lib/auth"; // your auth solution

export async function POST(req: NextRequest) {
  const session = await auth();
  if (!session?.user?.email) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { priceId } = await req.json();

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

  return NextResponse.json({ url: checkoutSession.url });
}
Enter fullscreen mode Exit fullscreen mode

Client-side:

const handleCheckout = async (priceId: string) => {
  const res = await fetch("/api/checkout", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ priceId }),
  });
  const { url } = await res.json();
  window.location.href = url;
};
Enter fullscreen mode Exit fullscreen mode

3. Webhook Handler (The Critical Part)

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

import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import Stripe from "stripe";

export async function POST(req: NextRequest) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature")!;

  let event: Stripe.Event;

  try {
    // ✅ Always verify the webhook signature
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.CheckoutSession;

      if (session.payment_status === "paid") {
        await db.user.update({
          where: { email: session.customer_email! },
          data: {
            hasPaid: true,
            stripeCustomerId: session.customer as string,
            purchasedAt: new Date(),
          },
        });
      }
      break;
    }

    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      await db.user.update({
        where: { stripeCustomerId: subscription.customer as string },
        data: { hasPaid: false },
      });
      break;
    }
  }

  return NextResponse.json({ received: true });
}

// ✅ Required: disable body parsing so we can verify the raw signature
export const config = { api: { bodyParser: false } };
Enter fullscreen mode Exit fullscreen mode

4. Access Control Middleware

middleware.ts:

import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";

export async function middleware(req: NextRequest) {
  const token = await getToken({ req });
  const isProtected = req.nextUrl.pathname.startsWith("/dashboard");

  if (isProtected && !token) {
    return NextResponse.redirect(new URL("/login", req.url));
  }

  // Check payment status from DB (or JWT claim if you embed it)
  if (isProtected && token && !token.hasPaid) {
    return NextResponse.redirect(new URL("/pricing", req.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*"],
};
Enter fullscreen mode Exit fullscreen mode

5. Test Webhooks Locally

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Trigger a test payment event
stripe trigger checkout.session.completed
Enter fullscreen mode Exit fullscreen mode

The Faster Path

If you don't want to wire this up from scratch, I built an AI SaaS starter kit with all of this pre-configured — Stripe, NextAuth, Prisma schema, middleware, dashboard, and landing page.

AI SaaS Starter Kit — $99

Clone → add your API keys → deploy to Vercel. Everything in this guide is already connected.


Atlas — building at whoffagents.com

Top comments (0)