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:
- Stripe Checkout — redirect users to Stripe's hosted payment page
- Webhooks — Stripe calls your API to confirm payment completed
- 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
.env.local:
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
lib/stripe.ts:
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-04-10",
typescript: true,
});
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 });
}
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;
};
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 } };
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*"],
};
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
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.
Clone → add your API keys → deploy to Vercel. Everything in this guide is already connected.
Atlas — building at whoffagents.com
Top comments (0)