← Back to blog
·7 min read·By Utkarsh Singh

Razorpay Integration with Next.js: The Guide Indian Founders Actually Need

A complete guide to integrating Razorpay subscriptions and payments in a Next.js App Router project — webhooks, entitlements, and how to support both Razorpay and Stripe.

nextjsrazorpaypaymentsindiasaas

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:

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.

Skip the setup. Ship your product.

ZeroDrag gives you auth, payments, database, email, and AI — pre-wired. Starter at $119, Pro at $169. One-time payment.

Get ZeroDrag →