Razorpay Integration with Next.js: The Guide Indian Founders Actually Need
If you're building a SaaS product in India and targeting Indian customers, Stripe is not your best option. Stripe's Indian payouts have historically been complicated, and a large portion of Indian users simply don't have international cards. Razorpay is the dominant payment gateway for the Indian market — better acceptance rates, UPI support, and straightforward INR payouts.
This guide covers a full Razorpay integration in Next.js App Router — subscriptions, webhooks, entitlement syncing, and how to build your payment layer so you can support both Razorpay and Stripe without rewriting your codebase.
Razorpay vs Stripe: The Honest Comparison for Indian Founders
| Factor | Razorpay | Stripe | |---|---|---| | INR payouts | ✅ Native | ❌ Complicated | | UPI support | ✅ Yes | ❌ No | | Indian cards | ✅ High acceptance | ⚠️ Lower acceptance | | International cards | ⚠️ Limited | ✅ Excellent | | Subscription billing | ✅ Yes | ✅ Yes | | Webhook reliability | ✅ Good | ✅ Excellent | | Documentation | ✅ Good | ✅ Excellent | | Dashboard UX | ✅ Good | ✅ Better |
The practical answer: If you're selling to Indian customers, use Razorpay. If you're selling globally, use Stripe. If you're doing both — and you should be — you need a payment abstraction layer that lets you switch between them.
Step 1: Set Up Razorpay
Install the Razorpay Node SDK:
npm install razorpay
Add to your .env.local:
RAZORPAY_KEY_ID=rzp_test_xxxxxxxxxx
RAZORPAY_KEY_SECRET=your_secret_key
NEXT_PUBLIC_RAZORPAY_KEY_ID=rzp_test_xxxxxxxxxx
Create a Razorpay client utility at lib/razorpay.ts:
import Razorpay from "razorpay";
export const razorpay = new Razorpay({
key_id: process.env.RAZORPAY_KEY_ID!,
key_secret: process.env.RAZORPAY_KEY_SECRET!,
});
Step 2: Create a Subscription Plan
Before creating subscriptions for users, you need a plan in Razorpay. Do this once via their dashboard or API:
// scripts/create-razorpay-plan.ts
import { razorpay } from "@/lib/razorpay";
async function createPlan() {
const plan = await razorpay.plans.create({
period: "monthly",
interval: 1,
item: {
name: "ZeroDrag Pro",
amount: 169900, // amount in paise (₹1699)
currency: "INR",
description: "ZeroDrag Pro — monthly subscription",
},
});
console.log("Plan created:", plan.id);
// Save this plan ID to your env vars as RAZORPAY_PLAN_ID
}
createPlan();
Run once:
npx ts-node scripts/create-razorpay-plan.ts
Add the plan ID to your env:
RAZORPAY_PLAN_ID=plan_xxxxxxxxxx
Step 3: Create a Checkout API Route
Create app/api/payments/razorpay/create-subscription/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { razorpay } from "@/lib/razorpay";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const subscription = await razorpay.subscriptions.create({
plan_id: process.env.RAZORPAY_PLAN_ID!,
customer_notify: 1,
total_count: 12, // 12 billing cycles (1 year)
notes: {
userId: session.user.id,
userEmail: session.user.email ?? "",
},
});
return NextResponse.json({
subscriptionId: subscription.id,
key: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID,
});
} catch (err) {
console.error("Razorpay subscription creation failed:", err);
return NextResponse.json(
{ error: "Failed to create subscription" },
{ status: 500 }
);
}
}
Step 4: Build the Checkout Component
Create components/RazorpayCheckout.tsx:
"use client";
import { useState } from "react";
declare global {
interface Window {
Razorpay: any;
}
}
interface RazorpayCheckoutProps {
userEmail: string;
userName: string;
}
export function RazorpayCheckout({ userEmail, userName }: RazorpayCheckoutProps) {
const [loading, setLoading] = useState(false);
const loadRazorpayScript = (): Promise<boolean> => {
return new Promise((resolve) => {
if (window.Razorpay) {
resolve(true);
return;
}
const script = document.createElement("script");
script.src = "https://checkout.razorpay.com/v1/checkout.js";
script.onload = () => resolve(true);
script.onerror = () => resolve(false);
document.body.appendChild(script);
});
};
const handleCheckout = async () => {
setLoading(true);
const loaded = await loadRazorpayScript();
if (!loaded) {
alert("Failed to load payment gateway. Please try again.");
setLoading(false);
return;
}
const res = await fetch("/api/payments/razorpay/create-subscription", {
method: "POST",
});
const { subscriptionId, key } = await res.json();
const options = {
key,
subscription_id: subscriptionId,
name: "ZeroDrag",
description: "Pro Plan — Monthly",
prefill: {
email: userEmail,
name: userName,
},
theme: {
color: "#000000",
},
handler: async (response: any) => {
// Verify payment server-side
await fetch("/api/payments/razorpay/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
razorpay_payment_id: response.razorpay_payment_id,
razorpay_subscription_id: response.razorpay_subscription_id,
razorpay_signature: response.razorpay_signature,
}),
});
window.location.href = "/dashboard?success=true";
},
};
const rzp = new window.Razorpay(options);
rzp.open();
setLoading(false);
};
return (
<button
onClick={handleCheckout}
disabled={loading}
className="w-full rounded-md bg-black text-white px-6 py-3 font-semibold"
>
{loading ? "Loading..." : "Subscribe with Razorpay"}
</button>
);
}
Step 5: Verify Payments Server-Side
Create app/api/payments/razorpay/verify/route.ts:
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const {
razorpay_payment_id,
razorpay_subscription_id,
razorpay_signature,
} = await req.json();
const generatedSignature = crypto
.createHmac("sha256", process.env.RAZORPAY_KEY_SECRET!)
.update(`${razorpay_payment_id}|${razorpay_subscription_id}`)
.digest("hex");
if (generatedSignature !== razorpay_signature) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// Payment verified — update user in database
await prisma.user.update({
where: { id: session.user.id },
data: {
razorpaySubscriptionId: razorpay_subscription_id,
plan: "pro",
stripeCurrentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
});
return NextResponse.json({ success: true });
}
Always verify the signature server-side. Never trust a client-side success callback alone.
Step 6: Handle Razorpay Webhooks
Create app/api/webhooks/razorpay/route.ts:
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { prisma } from "@/lib/prisma";
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get("x-razorpay-signature")!;
const expectedSignature = crypto
.createHmac("sha256", process.env.RAZORPAY_WEBHOOK_SECRET!)
.update(body)
.digest("hex");
if (expectedSignature !== signature) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const event = JSON.parse(body);
switch (event.event) {
case "subscription.activated":
await handleSubscriptionActivated(event.payload.subscription.entity);
break;
case "subscription.charged":
await handleSubscriptionCharged(event.payload.subscription.entity);
break;
case "subscription.cancelled":
await handleSubscriptionCancelled(event.payload.subscription.entity);
break;
case "payment.failed":
await handlePaymentFailed(event.payload.payment.entity);
break;
}
return NextResponse.json({ received: true });
}
async function handleSubscriptionActivated(subscription: any) {
const userId = subscription.notes?.userId;
if (!userId) return;
await prisma.user.update({
where: { id: userId },
data: {
razorpaySubscriptionId: subscription.id,
plan: "pro",
stripeCurrentPeriodEnd: new Date(subscription.current_end * 1000),
},
});
}
async function handleSubscriptionCharged(subscription: any) {
const userId = subscription.notes?.userId;
if (!userId) return;
await prisma.user.update({
where: { id: userId },
data: {
stripeCurrentPeriodEnd: new Date(subscription.current_end * 1000),
},
});
}
async function handleSubscriptionCancelled(subscription: any) {
const userId = subscription.notes?.userId;
if (!userId) return;
await prisma.user.update({
where: { id: userId },
data: {
plan: "free",
razorpaySubscriptionId: null,
},
});
}
async function handlePaymentFailed(payment: any) {
console.log("Payment failed:", payment.id);
// Send email notification to user
}
Building a Provider-Agnostic Payment Layer
If you're targeting both Indian and international customers, build a thin abstraction so you can switch providers via environment variable:
// lib/payments.ts
const PAYMENT_PROVIDER = process.env.PAYMENT_PROVIDER ?? "stripe"; // "stripe" | "razorpay"
export async function createCheckoutSession(userId: string, priceId: string) {
if (PAYMENT_PROVIDER === "razorpay") {
return createRazorpaySubscription(userId);
}
return createStripeCheckoutSession(userId, priceId);
}
This is the approach ZeroDrag uses — you set PAYMENT_PROVIDER=razorpay or PAYMENT_PROVIDER=stripe in your env and the rest of the codebase doesn't need to change.
Testing Razorpay Locally
Use Razorpay's test mode keys (prefixed with rzp_test_). In test mode, use these card numbers:
- Success:
4111 1111 1111 1111 - Failure:
4000 0000 0000 0002
For webhooks, use ngrok to expose your local server:
ngrok http 3000
Then add the ngrok URL to your Razorpay dashboard webhook settings.
The Bottom Line
A full Razorpay integration — subscriptions, webhooks, verification, and entitlement sync — takes about 6–8 hours to do correctly. If you also need Stripe support for international customers, that's another 6–9 hours on top.
ZeroDrag ships with both Stripe and Razorpay pre-integrated with a unified checkout API. Switch between providers by changing a single environment variable. No rewrites, no refactoring.
Written by Utkarsh Singh. Last updated March 2026.