The most expensive line in a checkout we audit is usually something like if (response.status === 'SUCCESS') markOrderPaid(), sitting in a React component. Anyone with browser dev tools can make that line run. Payment gateway integration in Next.js for India comes down to one rule: the browser starts the payment, the server decides whether it happened. Next.js App Router gives you the server pieces you need, route handlers and server actions, and this post shows how they map onto Cashfree, PayU and PhonePe.

The shape of a safe payment gateway integration in Next.js for India

Every Indian gateway we've worked with fits the same four steps, even though the names differ:

  1. Create the order on the server. A server action or route handler reads the cart from your database (never the price from the request), creates an order with the gateway using secret credentials, and stores the gateway's order reference against yours.
  2. Hand the browser something harmless. A session ID, a redirect URL, or a signed form. Nothing that lets the client change the amount.
  3. Receive the result on the server. A webhook, a return POST, or both, handled in a route handler that verifies a signature or hash against the raw request.
  4. Confirm with the gateway's status API before marking the order paid, and make that update idempotent.

Secrets live in server-only environment variables (no NEXT_PUBLIC_ prefix), and modules that touch them should import server-only so a mistaken client import fails the build instead of leaking a key.

Gateway by gateway

Cashfree

Your server calls Cashfree's create order API (/pg/orders, on the sandbox or production host) with the x-client-id, x-client-secret and x-api-version headers. The response includes a payment_session_id. That ID is what the browser gets. The client opens Cashfree's checkout with it, and there's no secret in the browser.

Cashfree webhooks carry x-webhook-signature and x-webhook-timestamp headers. Per Cashfree's docs, you concatenate the timestamp and the raw body, HMAC-SHA256 it with your secret key, base64-encode it, and compare. In App Router that looks like:

// app/api/webhooks/cashfree/route.ts
import crypto from 'node:crypto';

export const runtime = 'nodejs';

export async function POST(req: Request) {
  const raw = await req.text(); // raw body, not req.json()
  const ts = req.headers.get('x-webhook-timestamp') ?? '';
  const sig = req.headers.get('x-webhook-signature') ?? '';

  const expected = crypto
    .createHmac('sha256', process.env.CASHFREE_SECRET_KEY!)
    .update(ts + raw)
    .digest('base64');

  const ok = sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  if (!ok) return new Response('invalid signature', { status: 401 });

  const event = JSON.parse(raw);
  // look up the order, confirm with the order status API,
  // then update it idempotently
  return new Response('ok');
}

The detail people miss: read the body with req.text(). If you call req.json() and re-stringify, key order or whitespace can change and the signature won't match. Check Cashfree's docs for which key signs webhooks on your product and API version.

PayU

PayU's hosted checkout is an old-school form POST. Your server computes a SHA-512 hash over a pipe-separated string. PayU documents it as key|txnid|amount|productinfo|firstname|email|udf1|udf2|udf3|udf4|udf5||||||SALT. The browser posts the form, with the hash, to PayU. The salt never leaves the server, and PayU's own docs warn that leaking it lets an attacker alter a request and regenerate a valid hash.

When the customer finishes, PayU POSTs the result to your success or failure URL. That's an App Router gotcha: a page.tsx can't receive a POST body. Point surl and furl at a route handler, read the fields with await req.formData(), and verify the reverse hash, which PayU documents as SALT|status||||||udf5|udf4|udf3|udf2|udf1|email|firstname|productinfo|amount|txnid|key. Then redirect the browser to a normal page with a 303. Don't put the outcome in the query string as the source of truth. The page should read the order status from your database.

Confirm with PayU's verify payment API from the server too, especially for orders that never got a return POST because the customer closed the tab.

PhonePe

PhonePe's current integration is PG V2 (V1 with salt keys is deprecated). Your server fetches an OAuth token, cached until shortly before its expires_at, and creates a payment at /checkout/v2/pay with a merchantOrderId, the amount in paise, and a redirect URL. PhonePe returns a URL to send the customer to.

When they come back, don't trust the redirect. Call the order status API from the server. Webhooks arrive with an Authorization header that's SHA256(username:password) of credentials you set on the Dashboard. PhonePe says to rely on the root-level payload.state for status, and to fall back to the status API if the webhook doesn't arrive. PhonePe also says CORS errors mean you're calling their API from the frontend. Move that call into a route handler.

Things that break in production

  • Edge runtime. Webhook handlers that use node:crypto should declare export const runtime = 'nodejs'. Web Crypto works on the edge too, but don't mix them by accident.
  • Middleware. Auth middleware that redirects unauthenticated requests will happily redirect a gateway's webhook. Exclude your webhook paths in the matcher.
  • Duplicate events. Gateways retry. Store a processed-event key (gateway event ID, or order ID plus status) with a unique constraint and return 200 for repeats.
  • Return 200 fast. Do the heavy work (emails, invoices, fulfilment) after recording the event, from a queue or background job. Slow handlers cause retries, and retries cause duplicates.
  • Caching. Order status pages must read fresh data. Opt them out of caching explicitly so a "pending" page isn't served to someone who already paid.
  • Amounts. Store paise as integers. Converting rupees with floats is how ₹19.99 becomes 1998.9999999999998 paise in JavaScript.

Our production payment work includes a portal with multiple Indian gateways, GST invoicing, EMI plans, refunds and reconciliation for an ed-tech platform, and a native Razorpay integration for a commerce client. The same four steps applied every time.

If you're building a checkout on Next.js and want it reviewed or built with the server doing the deciding, orithLabs can help through our web development service. We price the work as fixed milestones before writing code, and the first call is free.