Cashfree card components inside the card, in the App Router
app/pay/checkout.tsx
"use client";
import { useEffect, useId, useRef } from "react";
import { useRouter } from "next/navigation";
import { load, type Cashfree, type CashfreeComponent } from "@cashfreepayments/cashfree-js";
import CardCheckout, { type CardFieldsSlot } from "@/components/CardCheckout";
import { verifyOrder } from "./actions";
type Order = { orderId: string; paymentSessionId: string };
type Confirm = (name: string) => Promise<void>;
type Field = "number" | "expiry" | "cvc";
// load() makes a new Cashfree object on every call, so keep one per page.
let sdk: Promise<Cashfree | null> | undefined;
const loadCashfree = () =>
(sdk ??= load({ mode: process.env.NEXT_PUBLIC_CASHFREE_ENV === "production" ? "production" : "sandbox" }));
// The fields are iframes and don't read your CSS, so their text is styled here.
const style = { base: { color: "#f4f5f7", fontSize: "18px", backgroundColor: "transparent" } };
function CashfreeFields({ slot, order, onReady }: {
slot: CardFieldsSlot;
order: Order;
onReady: (confirm: Confirm | null) => void;
}) {
const id = useId();
const box = (name: string) => `#${CSS.escape(id + name)}`;
const failed = useRef<HTMLParagraphElement>(null);
useEffect(() => {
let gone = false;
let parts: CashfreeComponent[] = [];
const done: Record<Field, boolean> = { number: false, expiry: false, cvc: false };
const changed = (field: Field, complete: boolean) => {
done[field] = complete;
slot.onChange({ complete: done.number && done.expiry && done.cvc });
};
(async () => {
const cashfree = await loadCashfree();
if (!cashfree || gone) return;
const number = cashfree.create("cardNumber", { values: { placeholder: "1234 5678 9012 3456" }, style });
const expiry = cashfree.create("cardExpiry", { style });
const cvv = cashfree.create("cardCvv", { style });
// The card has its own Cardholder name input. Cashfree still wants its cardHolder
// component for a card payment, so one is mounted out of sight and gets the name on Pay.
const holder = cashfree.create("cardHolder", { style });
parts = [number, expiry, cvv, holder];
number.on("change", (d) => {
changed("number", d.complete);
slot.onChange({ brand: d.value.brand ?? null }); // visa, mastercard, amex, rupay
if (d.value.cvvLength) cvv.update({ cvvLength: d.value.cvvLength });
});
expiry.on("change", (d) => changed("expiry", d.complete));
cvv.on("change", (d) => changed("cvc", d.complete));
number.on("focus", () => slot.onChange({ focus: "number" }));
expiry.on("focus", () => slot.onChange({ focus: "expiry" }));
cvv.on("focus", () => slot.onChange({ focus: "cvc" })); // flips the card
cvv.on("blur", () => slot.onChange({ focus: null }));
number.on("loaderror", (d) => {
if (failed.current) failed.current.textContent = d.error?.message ?? "The card fields didn't load.";
});
number.mount(box("number"));
expiry.mount(box("expiry"));
cvv.mount(box("cvv"));
holder.mount(box("holder"));
// pay() goes on the card number component; Cashfree reads the other three by itself.
onReady(async (name) => {
holder.update({ cardHolder: name });
const result = await cashfree.pay({
paymentMethod: number,
paymentSessionId: order.paymentSessionId,
redirect: "if_required", // stay on the page when the card needs no redirect
});
if (result.error) throw new Error(result.error.message);
if (result.redirect) return new Promise<void>(() => {}); // Cashfree is taking the buyer to 3D Secure
const { paid } = await verifyOrder(order.orderId);
if (!paid) throw new Error("That payment didn't go through.");
});
})();
return () => {
gone = true;
onReady(null);
parts.forEach((p) => p.destroy());
};
}, [slot, order, onReady]);
return (
<div className="space-y-3">
<div>
<label className={slot.labelClassName}>Card number</label>
<div id={id + "number"} className={slot.fieldClassName} />
</div>
<div className="flex gap-3">
<div className="flex-1">
<label className={slot.labelClassName}>Expiry</label>
<div id={id + "expiry"} className={slot.fieldClassName} />
</div>
<div className="flex-1">
<label className={slot.labelClassName}>CVV</label>
<div id={id + "cvv"} className={slot.fieldClassName} />
</div>
</div>
<div id={id + "holder"} inert className="sr-only" />
<p ref={failed} role="alert" className="text-sm text-red-400" />
</div>
);
}
export default function CashfreeCardCheckout({ order, amount }: { order: Order; amount: number }) {
const router = useRouter();
const confirm = useRef<Confirm | null>(null);
const setConfirm = useRef((c: Confirm | null) => (confirm.current = c)).current;
return (
<CardCheckout
amount={amount}
currency="INR"
locale="en-IN"
resetAfter={null}
renderCardFields={(slot) => <CashfreeFields slot={slot} order={order} onReady={setConfirm} />}
onConfirm={async ({ name }) => {
if (!confirm.current) throw new Error("The card fields haven't loaded yet.");
await confirm.current(name);
router.push("/thanks");
}}
/>
);
}
app/pay/page.tsx
import { createOrder } from "@/lib/cashfree";
import CashfreeCardCheckout from "./checkout";
export const dynamic = "force-dynamic";
export default async function PayPage() {
// The amount is set here on the server. A fresh order (and payment session) for every visit.
// Cashfree needs a customer id and phone on every order: use your signed-in buyer's.
const order = await createOrder(10, { id: "guest_demo", phone: "9999999999" });
return <CashfreeCardCheckout order={order} amount={10} />;
}
app/pay/actions.ts (Server Action: ask Cashfree whether the order is paid)
"use server";
import { isPaid } from "@/lib/cashfree";
// The browser only says "the payment finished". Ask Cashfree before you show success.
export async function verifyOrder(orderId: string) {
return { paid: await isPaid(orderId) };
}
lib/cashfree.ts (server: the cashfree-pg client, create and read orders)
import { Cashfree, CFEnvironment } from "cashfree-pg";
// Server only: never import this from a "use client" file. The client secret stays on the server.
// cashfree-pg 6.x sends x-api-version 2026-01-01 unless you set cashfree.XApiVersion.
export const cashfree = new Cashfree(
process.env.CASHFREE_ENV === "production" ? CFEnvironment.PRODUCTION : CFEnvironment.SANDBOX,
process.env.CASHFREE_CLIENT_ID!,
process.env.CASHFREE_CLIENT_SECRET!,
undefined, // partner key
undefined, // client signature
undefined, // partner merchant id
false, // error analytics: true by default, which calls Sentry.init in your server with Cashfree's project
);
// One order per payment attempt. Its payment_session_id is single use.
export async function createOrder(amount: number, customer: { id: string; phone: string }) {
const { data } = await cashfree.PGCreateOrder({
order_id: `order_${crypto.randomUUID().replaceAll("-", "")}`.slice(0, 45),
order_amount: amount, // in rupees, not paise: 10 is 10.00 INR
order_currency: "INR",
customer_details: { customer_id: customer.id, customer_phone: customer.phone },
order_meta: { return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pay/return?order_id={order_id}` },
});
return { orderId: data.order_id!, paymentSessionId: data.payment_session_id! };
}
// The source of truth: Cashfree's own order status, read from your server.
export async function isPaid(orderId: string) {
const { data } = await cashfree.PGFetchOrder(orderId);
return data.order_status === "PAID";
}
lib/cashfree-js.d.ts (types for the browser SDK, which ships none)
// @cashfreepayments/cashfree-js 1.0.7 ships no TypeScript types, so these are ours,
// written from Cashfree's JS SDK docs (read October 26, 2026). Only what this page uses.
declare module "@cashfreepayments/cashfree-js" {
export type ComponentData = {
value: { brand?: string; cvvLength?: number | string; [key: string]: unknown };
error?: { message: string; type?: string };
invalid?: boolean;
empty: boolean;
complete: boolean;
ready: boolean;
focus: boolean;
componentName: string;
};
export type ComponentEvent =
| "ready" | "focus" | "blur" | "invalid" | "change" | "empty" | "complete" | "loaderror" | "paymentrequested";
export type ComponentOptions = {
values?: Record<string, unknown>;
style?: Record<string, unknown>; // CSS in camelCase, applied inside the iframe
classes?: Partial<Record<"base" | "complete" | "empty" | "focus" | "invalid" | "disabled", string>>; // on your container
fonts?: { cssSrc: string }[];
disabled?: boolean;
loader?: boolean;
};
export interface CashfreeComponent {
mount(selector: string): void;
update(values: Record<string, unknown>): void;
unmount(): void;
destroy(): void;
data(): ComponentData;
isComplete(): boolean;
on(event: ComponentEvent, callback: (data: ComponentData) => void): void;
}
export type CashfreeResult = {
error?: { message: string; type?: string; code?: string };
redirect?: boolean;
paymentDetails?: { paymentMessage: string };
};
export interface Cashfree {
checkout(options: {
paymentSessionId: string;
redirectTarget?: "_self" | "_blank" | "_top" | "_modal" | HTMLElement;
returnUrl?: string;
appearance?: { width?: string; height?: string };
}): Promise<CashfreeResult>;
create(
name: "cardNumber" | "cardHolder" | "cardExpiry" | "cardCvv" | "savePaymentInstrument",
options?: ComponentOptions,
): CashfreeComponent;
pay(options: {
paymentMethod: CashfreeComponent;
paymentSessionId: string;
returnUrl?: string;
redirect?: "if_required";
redirectTarget?: "_self" | "_blank" | "_parent" | "_top";
savePaymentInstrument?: CashfreeComponent;
}): Promise<CashfreeResult>;
}
// Resolves to null on the server. Each call makes a new Cashfree object, so call it once per page.
export function load(options: { mode: "sandbox" | "production" }): Promise<Cashfree | null>;
}