How to Add Stripe to Next.js (2026)
How to Add Stripe to Next.js (2026): The Ultimate Integration Guide
/api/checkoutLive demo: stripe-demo.devkitmarket.com GitHub repo: github.com/devkit-market/nextjs-stripe-2026
5-Minute Overview — The Workflow
- Environment Setup: Get your API keys and configure .text
.env.local - Stripe Singleton: Create a typed Stripe instance you can import anywhere.
- Checkout Session: Build a Server Action that returns a .text
client_secret - Embedded Checkout UI: Mount Stripe's checkout inside your own page (no redirects).
- Webhook Fulfillment: Verify events with the SDK and update your database safely.
- Customer Portal: Let users manage subscriptions themselves with one extra route.
Step 1 — API Keys & Dependencies
npm install stripe @stripe/stripe-js @stripe/react-stripe-js
.env.local# .env.local
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_BASE_URL=http://localhost:3000
Step 2 — The Stripe Singleton
lib/stripe.tsimport Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-09-30.clover", // Pin to a known version, never auto-upgrade
typescript: true,
});
Step 3 — Creating a Checkout Session (Server Action)
app/actions/stripe.ts"use server";
import { stripe } from "@/lib/stripe";
import { headers } from "next/headers";
export async function createCheckoutSession(priceId: string) {
// Always look up price on the server — never trust the client
const price = await stripe.prices.retrieve(priceId);
if (!price.active) throw new Error("Price is not active");
const origin = (await headers()).get("origin") || process.env.NEXT_PUBLIC_BASE_URL!;
const session = await stripe.checkout.sessions.create({
ui_mode: "embedded", // 2026 default — keeps users on your domain
line_items: [{ price: priceId, quantity: 1 }],
mode: price.recurring ? "subscription" : "payment",
return_url: `${origin}/return?session_id={CHECKOUT_SESSION_ID}`,
automatic_tax: { enabled: true },
allow_promotion_codes: true,
});
if (!session.client_secret) throw new Error("Could not create session");
return { clientSecret: session.client_secret };
}
ui_mode: "embedded"client_secretStep 4 — The Embedded Checkout UI
app/checkout/page.tsx"use client";
import { useCallback } from "react";
import { loadStripe } from "@stripe/stripe-js";
import {
EmbeddedCheckoutProvider,
EmbeddedCheckout,
} from "@stripe/react-stripe-js";
import { createCheckoutSession } from "@/actions/stripe";
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
export default function CheckoutPage({
searchParams,
}: {
searchParams: { priceId: string };
}) {
const fetchClientSecret = useCallback(async () => {
const { clientSecret } = await createCheckoutSession(searchParams.priceId);
return clientSecret;
}, [searchParams.priceId]);
return (
<div id="checkout" className="min-h-screen p-8">
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{ fetchClientSecret }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
);
}
ui_mode: "embedded"ui_mode: "hosted"redirect(session.url!)Step 5 — Webhook Implementation (Critical)
app/api/webhook/stripe/route.tsimport { headers } from "next/headers";
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { stripe } from "@/lib/stripe";
export async function POST(req: Request) {
const body = await req.text(); // raw body — do NOT use req.json()
const signature = (await headers()).get("stripe-signature");
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature!,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
return NextResponse.json(
{ error: `Webhook Error: ${err.message}` },
{ status: 400 }
);
}
// Idempotency check — Stripe may retry the same event
const alreadyProcessed = await db.webhookEvent.findUnique({
where: { stripeId: event.id },
});
if (alreadyProcessed) {
return NextResponse.json({ received: true, duplicate: true });
}
// Handle the event
switch (event.type) {
case "checkout.session.completed":
const session = event.data.object as Stripe.Checkout.Session;
await fulfillOrder(session); // grant access, send email, etc.
break;
case "invoice.paid":
await extendSubscription(event.data.object as Stripe.Invoice);
break;
case "customer.subscription.deleted":
await revokeAccess(event.data.object as Stripe.Subscription);
break;
case "invoice.payment_failed":
await notifyDunning(event.data.object as Stripe.Invoice);
break;
default:
console.log(`Unhandled event type ${event.type}`);
}
// Mark as processed only AFTER fulfillment succeeds
await db.webhookEvent.create({ data: { stripeId: event.id } });
return NextResponse.json({ received: true });
}
stripe loginstripe listen --forward-to localhost:3000/api/webhook/stripewhsec_....env.localStep 6 — The Customer Portal
app/actions/portal.ts"use server";
import { stripe } from "@/lib/stripe";
import { auth } from "@/lib/auth"; // your auth provider
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export async function openCustomerPortal() {
const { userId } = await auth();
if (!userId) throw new Error("Unauthorized");
const user = await db.user.findUnique({ where: { id: userId } });
if (!user?.stripeCustomerId) throw new Error("No Stripe customer");
const origin = (await headers()).get("origin")!;
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${origin}/dashboard`,
});
redirect(portalSession.url);
}
<form action={openCustomerPortal}>
<button className="px-4 py-2 bg-slate-900 text-white rounded-lg">
Manage Subscription
</button>
</form>
Step 7 — Production Checklist
- Switch to Live Keys: Replace andtext
sk_test_withtextpk_test_andtextsk_live_in your production environment (Vercel, Netlify, etc). Never commit live keys to git.textpk_live_ - Register Production Webhook: Add a new endpoint pointing to your production URL in Dashboard → Webhooks, and use that secret — test and live webhooks have different secrets.
- Webhook Idempotency: Confirm your DB has the table or equivalent. A retried webhook without idempotency = duplicate orders.text
webhookEvent - Server-Side Price Validation: Always retrieve the price from Stripe or your DB inside the Server Action. Never trust an amount the client sends.
- Enable Stripe Tax: Turn it on in Dashboard → Tax to handle VAT, GST, and US sales tax automatically. One toggle saves a tax accountant.
- Set Up Radar Rules: Stripe's fraud tool is on by default but customize the rules for your risk tolerance under Dashboard → Radar.
- Branding: Update logo, brand color, and accent in Dashboard → Settings → Branding so Embedded Checkout matches your site.
- Test the Full Failure Path: Use card (succeeds then disputes) andtext
4000 0000 0000 0341(insufficient funds) before going live.text4000 0000 0000 9995
Conclusion
Skip the setup and start shipping
Love this guide? All these patterns are pre-configured in our **SaaS Starter Pro** kit. Save 40+ hours of development.
Explore the KitRelated Articles
Selected insights to level up your development workflow.
How to Add Razorpay to Next.js (2026): Complete Guide with Code
Step-by-step guide to integrate Razorpay payment gateway in Next.js 15 with App Router, TypeScript, webhooks, and refunds.
Next.js + Prisma + Stripe Tutorial
Learn how to build a subscription-based SaaS using the powerhouse trio of Next.js, Prisma, and Stripe.
Next.js Authentication with Clerk — Complete Guide
Why Clerk is our top choice for SaaS authentication and how to set it up in under 15 minutes.
Keep building with free resources
Production-ready starter kits and zero-friction developer tools — the same ones we use to ship our own products.
Starter Kits
Next.js Blog Kit
MDX-powered blog with full SEO, dark mode, RSS feed, reading time, and syntax highlighting. Deploy to Vercel in one click.
Developer Tools
Shadcn/UI Component Previewer
Live preview of shadcn/ui components with instant copy-paste code. Browse rendered components and grab snippets.
Next.js Project Structure Generator
8.5kSelect your stack and instantly get a production-ready folder structure. Copy the entire scaffold in one click.
.env File Generator
24kPick your tech stack and get a complete, commented .env boilerplate file. Never forget an environment variable.
Tailwind CSS Color Palette Generator
15k+Enter a brand color and generate a complete Tailwind-compatible shade scale with config snippets.
Looking for something specific?
Browse the full library — 7+ kits across 4+ categories.