Add Razorpay to a Next.js Template (2026)
Add Razorpay to a Next.js Template in 2026
Razorpay is the default payment gateway for Indian SaaS and e-commerce. It accepts every payment method Indian customers actually use (UPI, net banking, cards, wallets, EMI) and the developer experience is solid. Stripe is great if you're selling outside India, but inside India, Razorpay is the safer call.
This guide walks the full wiring: install the SDK, create the checkout flow, handle webhooks, manage subscriptions, and the patterns that survive contact with Indian payment regulations.
Save the integration time entirely: get every kit for $499
The SaaS Starter Kit ships with Razorpay integration patterns and a billing UI ready to wire. Most of the integration work is already done. All Access unlocks every kit on thefrontkit for $499 one-time.
Why Razorpay Over Stripe in India
Indian customers expect specific payment methods:
- UPI (Google Pay, PhonePe, Paytm) — the dominant retail method, near-zero fees
- Net banking — older but still 15-20% of transactions
- Cards — domestic Rupay and international Visa/Mastercard
- EMI — for higher-ticket purchases
- Wallets — Paytm, Mobikwik, others
Stripe in India accepts cards and a limited set of methods. Razorpay accepts everything. For a SaaS pricing at ₹999/month, the difference is 10-15% conversion in Razorpay's favor.
The alternatives:
- Stripe (India) — works for international cards, limited domestic methods. Fine for B2B SaaS targeting global customers.
- Cashfree — comparable to Razorpay, smaller ecosystem.
- Instamojo — smaller, focused on solo sellers.
- PayU — older, more enterprise-focused.
For most India-headquartered SaaS, Razorpay is the default.
Step 1: Create the Razorpay Account
Sign up at razorpay.com. The dashboard has both Test Mode and Live Mode. In Test Mode you can build the full integration without real money moving.
Grab two keys from the dashboard:
RAZORPAY_KEY_ID(e.g.,rzp_test_xxxin test mode,rzp_live_xxxin live)RAZORPAY_KEY_SECRET— server-side only
For Live Mode you need KYC verification, which takes 2-3 business days. Build everything in Test Mode first.
Step 2: Install the SDK
npm install razorpay
Add to .env.local:
NEXT_PUBLIC_RAZORPAY_KEY_ID=rzp_test_xxx
RAZORPAY_KEY_SECRET=xxx
RAZORPAY_WEBHOOK_SECRET=xxx
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Create a client wrapper at lib/razorpay.ts:
import Razorpay from 'razorpay';
export const razorpay = new Razorpay({
key_id: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID!,
key_secret: process.env.RAZORPAY_KEY_SECRET!,
});
Step 3: Create the Order (Server-Side)
Razorpay's flow is different from Stripe Checkout. You create an Order on the server, then open the Razorpay Checkout modal on the client with the order ID.
Server action:
'use server';
import { razorpay } from '@/lib/razorpay';
import { auth } from '@clerk/nextjs/server';
export async function createRazorpayOrder(amountInPaise: number, planId?: string) {
const { userId } = await auth();
if (!userId) throw new Error('Unauthorized');
const order = await razorpay.orders.create({
amount: amountInPaise, // in paise. ₹999 = 99900
currency: 'INR',
receipt: `order_${Date.now()}`,
notes: {
userId,
planId: planId || '',
},
});
return {
orderId: order.id,
amount: order.amount,
currency: order.currency,
keyId: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID,
};
}
Amount is in paise (1 INR = 100 paise). Store the userId and planId in notes so you can recover them in the webhook.
Step 4: Trigger the Checkout Modal (Client-Side)
Load the Razorpay Checkout script in app/layout.tsx:
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Script src="https://checkout.razorpay.com/v1/checkout.js" strategy="lazyOnload" />
{children}
</body>
</html>
);
}
Open the modal from a button:
'use client';
import { createRazorpayOrder } from '@/app/actions/razorpay';
declare global {
interface Window {
Razorpay: any;
}
}
export function PayButton({ amountInPaise, planId }: { amountInPaise: number; planId: string }) {
async function handlePay() {
const order = await createRazorpayOrder(amountInPaise, planId);
const rzp = new window.Razorpay({
key: order.keyId,
amount: order.amount,
currency: order.currency,
order_id: order.orderId,
name: 'Your Product Name',
description: 'Pro Plan',
handler: async (response: any) => {
// Verify payment on server
await verifyPayment({
razorpay_order_id: response.razorpay_order_id,
razorpay_payment_id: response.razorpay_payment_id,
razorpay_signature: response.razorpay_signature,
});
window.location.href = '/billing/success';
},
prefill: {
email: 'user@example.com',
contact: '9999999999',
},
theme: { color: '#7c3aed' },
});
rzp.open();
}
return <button onClick={handlePay}>Pay ₹{amountInPaise / 100}</button>;
}
The handler fires when the user completes payment. You receive a razorpay_payment_id and razorpay_signature which you must verify on the server.
Step 5: Verify the Payment Signature
Never trust the client. Verify the signature on the server before granting access.
'use server';
import crypto from 'crypto';
import { db } from '@/lib/db';
import { auth } from '@clerk/nextjs/server';
export async function verifyPayment({
razorpay_order_id,
razorpay_payment_id,
razorpay_signature,
}: {
razorpay_order_id: string;
razorpay_payment_id: string;
razorpay_signature: string;
}) {
const { userId } = await auth();
if (!userId) throw new Error('Unauthorized');
const body = `${razorpay_order_id}|${razorpay_payment_id}`;
const expectedSignature = crypto
.createHmac('sha256', process.env.RAZORPAY_KEY_SECRET!)
.update(body)
.digest('hex');
if (expectedSignature !== razorpay_signature) {
throw new Error('Invalid payment signature');
}
// Payment is verified. Grant access.
await db.purchase.create({
data: {
userId,
razorpayOrderId: razorpay_order_id,
razorpayPaymentId: razorpay_payment_id,
status: 'completed',
},
});
return { success: true };
}
The signature check is the security boundary. Without it, anyone could POST fake order/payment IDs and claim they paid.
Step 6: Wire the Webhook (Defensive)
Webhooks are the safety net for cases where the client-side handler doesn't fire (e.g., the user closes the tab after paying). Wire them.
Create app/api/webhooks/razorpay/route.ts:
import { headers } from 'next/headers';
import crypto from 'crypto';
import { db } from '@/lib/db';
export async function POST(req: Request) {
const body = await req.text();
const signature = (await headers()).get('x-razorpay-signature');
if (!signature) {
return new Response('Missing signature', { status: 400 });
}
const expectedSignature = crypto
.createHmac('sha256', process.env.RAZORPAY_WEBHOOK_SECRET!)
.update(body)
.digest('hex');
if (expectedSignature !== signature) {
return new Response('Invalid signature', { status: 400 });
}
const event = JSON.parse(body);
switch (event.event) {
case 'payment.captured': {
const payment = event.payload.payment.entity;
const userId = payment.notes?.userId;
const planId = payment.notes?.planId;
if (userId) {
await db.purchase.upsert({
where: { razorpayPaymentId: payment.id },
create: {
userId,
planId,
razorpayOrderId: payment.order_id,
razorpayPaymentId: payment.id,
amount: payment.amount,
status: 'completed',
},
update: { status: 'completed' },
});
}
break;
}
case 'payment.failed': {
const payment = event.payload.payment.entity;
await db.purchase.upsert({
where: { razorpayPaymentId: payment.id },
create: {
razorpayOrderId: payment.order_id,
razorpayPaymentId: payment.id,
status: 'failed',
},
update: { status: 'failed' },
});
break;
}
case 'subscription.activated':
case 'subscription.charged':
case 'subscription.cancelled': {
const sub = event.payload.subscription.entity;
await db.subscription.upsert({
where: { razorpaySubscriptionId: sub.id },
create: {
userId: sub.notes?.userId,
razorpaySubscriptionId: sub.id,
status: sub.status,
},
update: { status: sub.status },
});
break;
}
}
return new Response('OK');
}
In the Razorpay dashboard:
- Settings > Webhooks > Add new
- URL:
https://your-app.com/api/webhooks/razorpay - Subscribe to
payment.captured,payment.failed,subscription.activated,subscription.charged,subscription.cancelled - Set a webhook secret and copy it into
RAZORPAY_WEBHOOK_SECRET
Step 7: Subscriptions (Recurring Billing)
Razorpay subscriptions are different from Stripe. You create a Plan first, then a Subscription, then your customer authorizes recurring payment via a mandate.
Server action:
'use server';
import { razorpay } from '@/lib/razorpay';
import { auth } from '@clerk/nextjs/server';
export async function createRazorpaySubscription(planId: string) {
const { userId } = await auth();
if (!userId) throw new Error('Unauthorized');
const subscription = await razorpay.subscriptions.create({
plan_id: planId,
customer_notify: 1,
total_count: 12, // billing cycles
notes: { userId },
});
return {
subscriptionId: subscription.id,
keyId: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID,
};
}
Open the subscription checkout:
const sub = await createRazorpaySubscription(planId);
const rzp = new window.Razorpay({
key: sub.keyId,
subscription_id: sub.subscriptionId,
name: 'Your Product',
description: 'Pro Subscription',
handler: async (response: any) => {
await verifySubscriptionPayment(response);
},
});
rzp.open();
Subscriptions in India require eMandate authorization, which has different flows for cards vs UPI. Plan for 2-3 days of testing edge cases.
Common Gotchas
Forgetting signature verification. Without it, anyone can POST fake order/payment IDs. Always verify both the client-handler payment and the webhook signature.
Amount in rupees, not paise. Razorpay expects paise (1 INR = 100 paise). ₹999 must be 99900, not 999. Wrong-unit bugs are silent and cost a 100x.
Test mode with live keys. Test orders only work with test keys; live orders need live keys. Mixing them up means every payment fails.
Skipping webhooks. If the user closes the tab mid-payment, the client-side handler doesn't fire. Without a webhook, you don't know they paid. Wire both.
Hardcoded notification phone numbers. The prefill.contact must be a valid Indian phone number for the UPI flow to work. Don't hardcode; pull from the user profile.
KYC delays. Live mode requires KYC verification (PAN, bank details, business proof). Plan for 2-3 business days minimum. Don't promise a launch date that depends on instant Live Mode activation.
Subscriptions without eMandate setup. Razorpay subscriptions need mandate registration with the customer's bank. This is a one-time flow per subscription. Customers may get confused if they don't see clear copy explaining what the mandate is.
GST charging. If you're a registered business in India, you must charge GST (18% for SaaS). Razorpay doesn't add it automatically; you build it into your displayed prices or add it as a separate line item.
Adjacent Reads
- Add Stripe Payments to a Next.js Template — for non-India payments
- How to Build a SaaS in Next.js — broader build path
- Add Clerk Auth to a Next.js Template — pair with auth
FAQ
Should I use Razorpay or Stripe in India? Razorpay for India-headquartered businesses selling primarily to Indian customers. Stripe if you're an Indian business selling primarily to global customers (e.g., a SaaS targeting US developers from a Bangalore HQ). The deciding factor is payment method coverage: Razorpay supports UPI (which 60-70% of Indian customers prefer); Stripe doesn't.
Can I use both Razorpay and Stripe in the same app? Yes. Route Indian customers (detected by billing country or IP) to Razorpay; route everyone else to Stripe. The integration overhead is roughly doubled, but the conversion improvement on Indian customers usually justifies it. Build the routing layer carefully so refunds and customer service map to the right provider.
How do I charge GST on Indian payments? Build GST into your displayed prices. If you charge ₹999 for the Pro plan, ₹847.46 is the base price and ₹152.54 is GST (18%). Razorpay doesn't calculate this for you. For B2B sales where the customer has a GSTIN, you'll need to issue a tax invoice with the customer's GSTIN, which Razorpay supports via the invoice product.
Do I need a current account to use Razorpay? For Live Mode, yes. Razorpay requires a business current account (not a savings account) for payouts. Sole proprietorships, LLPs, and private limited companies all qualify. The KYC process verifies the account.
What's the cost difference between Razorpay and Stripe? Razorpay: 2% for domestic transactions, 3% for international. Stripe in India: 2.95% + ₹3 for domestic, 4.4% for international. Razorpay is cheaper for domestic, comparable internationally. The fee difference is usually less important than the conversion difference from payment method coverage.
How do I refund a Razorpay payment?
Via the API: razorpay.payments.refund(paymentId, { amount: amountInPaise }). Partial refunds are supported. Refunds typically take 5-7 working days to reflect in the customer's account. Always email the customer with the refund ID and expected timeline.
