Integrating Stripe into a Next.js 15 App Router project involves more moving parts than it used to — Server Actions for creating checkout sessions, Route Handlers for webhooks, database updates to track subscription state, and a customer portal for self-service plan management. This guide covers the complete setup: one-time payments, subscription billing, webhook processing, and a frank look at where Stripe fits for Indian product builders versus alternatives like Razorpay.
Setting Up Stripe: API Keys and Test Mode
Start by installing the Stripe Node.js SDK:
npm install stripe @stripe/stripe-js
Create a Stripe client singleton to avoid instantiating a new client on every server request:
// lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-12-18.acacia",
typescript: true,
});
Your environment variables should be:
# .env.local
STRIPE_SECRET_KEY=sk_test_... # from Stripe Dashboard > API Keys
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... # from Stripe Dashboard > Webhooks
Keep STRIPE_SECRET_KEY strictly server-side — never expose it in client components. The publishable key (prefixed pk_) is safe for the browser. Switch both keys to live mode values for production by swapping sk_test_ for sk_live_ and pk_test_ for pk_live_.
One-Time Payment With Stripe Checkout
Stripe Checkout is the fastest path to a working payment UI — Stripe hosts the payment page, handles card validation, and manages PCI compliance. You create a checkout session on the server and redirect the user to it.
Create a Server Action that generates the checkout session:
// app/actions/checkout.ts
"use server";
import { stripe } from "@/lib/stripe";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export async function createCheckoutSession(priceId: string) {
const session = await auth();
if (!session) redirect("/login");
const checkoutSession = await stripe.checkout.sessions.create({
mode: "payment",
customer_email: session.user.email!,
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/payment/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
metadata: {
userId: session.user.id,
},
});
redirect(checkoutSession.url!);
}
Call this from a Client Component button:
"use client";
import { createCheckoutSession } from "@/app/actions/checkout";
export function BuyButton({ priceId }: { priceId: string }) {
return (
<form action={createCheckoutSession.bind(null, priceId)}>
<button type="submit">Purchase Now</button>
</form>
);
}
The success page reads the checkout session to confirm payment and display a confirmation:
// app/payment/success/page.tsx
import { stripe } from "@/lib/stripe";
export default async function SuccessPage({
searchParams,
}: {
searchParams: { session_id: string };
}) {
const checkoutSession = await stripe.checkout.sessions.retrieve(
searchParams.session_id
);
if (checkoutSession.payment_status !== "paid") {
return <p>Payment not confirmed yet. Please contact support.</p>;
}
return (
<div>
<h1>Payment successful</h1>
<p>Amount: {(checkoutSession.amount_total! / 100).toFixed(2)} {checkoutSession.currency?.toUpperCase()}</p>
</div>
);
}
Subscription Plans: Products, Prices, and Billing Cycles
Create your products and prices in the Stripe Dashboard rather than in code — this keeps your price IDs stable and avoids accidentally creating duplicate products in test and live mode. Set up a Basic plan (monthly) and a Pro plan (monthly and annual) with recurring prices in INR.
Switching from mode: "payment" to mode: "subscription" in the checkout session handles recurring billing:
// Subscription checkout — changes from one-time payment above
const checkoutSession = await stripe.checkout.sessions.create({
mode: "subscription",
customer_email: session.user.email!,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?subscribed=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
subscription_data: {
metadata: { userId: session.user.id },
trial_period_days: 14, // optional free trial
},
allow_promotion_codes: true, // let users enter coupon codes
});
Stripe automatically handles recurring billing — charging the card monthly (or annually), retrying on failure, and sending dunning emails. Your webhook handler (covered below) gets notified of every state change.
Webhook Setup With Next.js Route Handler
Webhooks are the backbone of a reliable payment system. Stripe sends events to your endpoint as payment status changes — do not rely solely on the success redirect URL to fulfil orders or activate subscriptions, because users can close their browser before reaching it.
// app/api/webhooks/stripe/route.ts
import { stripe } from "@/lib/stripe";
import { prisma } from "@/lib/prisma";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const rawBody = await request.text();
const signature = request.headers.get("stripe-signature")!;
let event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
const userId = session.metadata?.userId;
if (!userId) break;
if (session.mode === "subscription") {
await prisma.user.update({
where: { id: userId },
data: {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
subscriptionStatus: "active",
},
});
}
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object;
const customer = await stripe.customers.retrieve(
subscription.customer as string
);
// Find user by Stripe customer ID
await prisma.user.updateMany({
where: { stripeCustomerId: subscription.customer as string },
data: {
subscriptionStatus: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
priceId: subscription.items.data[0].price.id,
},
});
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object;
await prisma.user.updateMany({
where: { stripeCustomerId: subscription.customer as string },
data: {
subscriptionStatus: "canceled",
stripeSubscriptionId: null,
},
});
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object;
await prisma.user.updateMany({
where: { stripeCustomerId: invoice.customer as string },
data: { subscriptionStatus: "past_due" },
});
break;
}
}
return NextResponse.json({ received: true });
}
Register this endpoint in the Stripe Dashboard under Developers > Webhooks. Point it to https://yourapp.com/api/webhooks/stripe and subscribe to: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, and invoice.payment_failed.
Storing Subscription State in Prisma
Add the subscription fields to your User model:
// In prisma/schema.prisma
model User {
// ... existing fields
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
subscriptionStatus String? // "active" | "past_due" | "canceled" | "trialing"
priceId String?
currentPeriodEnd DateTime?
}
Gate premium features by checking subscriptionStatus in Server Components:
// app/dashboard/premium-feature/page.tsx
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
export default async function PremiumFeaturePage() {
const session = await auth();
if (!session) redirect("/login");
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { subscriptionStatus: true, currentPeriodEnd: true },
});
const hasAccess =
user?.subscriptionStatus === "active" ||
user?.subscriptionStatus === "trialing";
if (!hasAccess) redirect("/pricing");
return <div>Premium content here</div>;
}
Customer Portal for Self-Service Management
Stripe's Customer Portal lets users manage their subscription — upgrade, downgrade, cancel, update payment method — without you building any of that UI. Enable it in the Stripe Dashboard under Settings > Billing > Customer Portal, then create a portal session:
// app/actions/portal.ts
"use server";
import { stripe } from "@/lib/stripe";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
export async function openCustomerPortal() {
const session = await auth();
if (!session) redirect("/login");
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true },
});
if (!user?.stripeCustomerId) redirect("/pricing");
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
});
redirect(portalSession.url);
}
Indian Payment Considerations: Stripe vs Razorpay
Stripe has been available in India for sellers since 2020, supports INR pricing, and processes Indian cards. For international SaaS products with Indian users alongside global customers, Stripe's unified API is a strong argument — one integration covers cards worldwide, with INR available as a currency.
However, Razorpay is genuinely better for India-focused products in several areas. Razorpay's UPI integration is first-class — users can pay via UPI intent or UPI collect flow natively, with conversion rates that Stripe's payment element can't match for Indian consumers. Razorpay also supports netbanking for a wider range of Indian banks, NACH (National Automated Clearing House) for recurring debit mandates (important for Indian subscription billing), and EMI on Indian credit cards at zero additional development effort.
The practical decision: if more than 60% of your target users are in India and UPI adoption in your demographic is high, Razorpay's checkout converts better. If you're building a product with significant international reach or where the buyer persona uses credit cards (B2B SaaS, enterprise tools), Stripe's API quality and documentation are better for a development team. You can also run both — Stripe for international payments and Razorpay for INR transactions — with feature-flagged checkout flows per user geography.
Testing With Stripe CLI and Test Cards
Install the Stripe CLI and listen for webhook events locally during development:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
This command generates a temporary webhook secret for local testing. Use the test card numbers from Stripe's documentation for different scenarios:
// Test card numbers
4242 4242 4242 4242 — Successful payment (any future expiry, any CVV)
4000 0000 0000 0002 — Card declined
4000 0025 0000 3155 — Requires 3D Secure authentication
4000 0035 6000 0008 — Indian Rupee card (for INR testing)
4000 0000 0000 9995 — Insufficient funds
// For subscriptions, trigger events manually:
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
Test the complete payment flow before switching to live keys. Verify that your webhook handler correctly updates subscription status in the database for all the event types you handle, and that subscription gating in Server Components blocks access correctly when status is canceled or past_due.
Frequently Asked Questions
Does Stripe support INR payments and UPI in India?
Stripe does support INR as a currency and can process Indian debit and credit cards. However, Stripe's UPI support in India is limited — it's available only through Stripe's payment element in specific configurations and not as a first-class payment method the way Razorpay handles it. If UPI is a primary payment method for your users (which it is for most Indian consumers), Razorpay or PayU give you better UPI coverage with lower per-transaction friction. Stripe is the better choice when you're selling internationally and need a consistent payment API across multiple currencies, or when your Indian customers are primarily using credit cards for B2B subscriptions.
How do I handle the Stripe webhook signature verification in Next.js App Router?
The critical step is reading the raw request body before any parsing happens — Stripe uses the raw bytes to compute and verify the signature. In Next.js App Router Route Handlers, use await request.text() to get the raw body as a string, then pass it to stripe.webhooks.constructEvent(rawBody, signature, webhookSecret). Do not parse the body with request.json() before this step — JSON parsing modifies whitespace and formatting in ways that break the signature check. Also ensure your webhook Route Handler is not wrapped in any middleware that might buffer or transform the request body before it reaches your handler.
What is the safest way to store Stripe subscription status in my database?
Store the Stripe customer ID, subscription ID, current plan, subscription status (active, past_due, canceled, trialing), and the period end date. Never rely solely on client-side state or session data for access control — always read subscription status from your database, which gets updated by your webhook handler. Treat the webhook as the source of truth: when Stripe fires customer.subscription.updated or customer.subscription.deleted, update your database immediately. Add a lastWebhookAt timestamp so you can detect if webhook delivery has stalled. For critical paths like gating premium features, query your database directly rather than trusting cached session data that may be hours old.