The PayPal button and the 3D card, in the App Router
app/pay/checkout.tsx
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
import { useRouter } from "next/navigation";
import {
PayPalProvider,
PayPalOneTimePaymentButton,
PayPalCardFieldsProvider,
PayPalCardNumberField,
PayPalCardExpiryField,
PayPalCardCvvField,
usePayPalCardFieldsOneTimePaymentSession,
type EventPayload,
} from "@paypal/react-paypal-js/sdk-v6";
import CardCheckout, { type CardFieldsSlot } from "@/components/CardCheckout";
import { createPayPalOrder, capturePayPalOrder } from "./actions";
const AMOUNT = 10; // what the card shows; the server action sets what PayPal charges
// The card fields are PayPal iframes: style the input inside them here.
const style = {
input: { color: "#F4F4F6", fontFamily: "monospace", fontSize: "19px", background: "transparent", border: "none", boxShadow: "none", padding: "0" },
"input::placeholder": { color: "#4a4f61" },
};
const fill = { height: "100%" };
const brandOf = (e: EventPayload) => {
const type = e.data.cards.length === 1 ? e.data.cards[0].type : null;
return type === "master-card" ? "mastercard" : type;
};
const allValid = (e: EventPayload) => e.data.number.isValid && e.data.expiry.isValid && e.data.cvv.isValid;
type Outcome = { orderId?: string; error?: string };
function PayPalButton({ onError }: { onError: (message: string) => void }) {
const router = useRouter();
return (
<PayPalOneTimePaymentButton
type="pay"
// The server creates the order. The browser only ever sees its id.
createOrder={async () => ({ orderId: await createPayPalOrder() })}
onApprove={async ({ orderId }) => {
const { completed } = await capturePayPalOrder(orderId);
if (!completed) return onError("PayPal didn't complete that payment. Try again or pay by card.");
router.push(`/thanks?order=${orderId}`);
}}
onCancel={() => onError("Payment cancelled. Your cart is still here.")}
onError={(e) => onError(e.message || "PayPal couldn't start. Try again.")}
/>
);
}
function CardForm({ slot }: { slot: RefObject<CardFieldsSlot | null> }) {
const router = useRouter();
const { submit, submitResponse, error } = usePayPalCardFieldsOneTimePaymentSession();
// submit() doesn't throw: its result lands in submitResponse or error.
const waiting = useRef<((o: Outcome) => void) | null>(null);
useEffect(() => {
const done = waiting.current;
if (!done || (!error && !submitResponse)) return;
waiting.current = null;
if (error) return done({ error: error.message });
const { state, data } = submitResponse!;
if (state === "succeeded") return done({ orderId: data.orderId });
done({ error: state === "canceled" ? "The card check was cancelled. Try again." : data.message || "That card didn't go through." });
}, [submitResponse, error]);
return (
<CardCheckout
amount={AMOUNT}
currency="USD"
productName="Pro plan"
resetAfter={null}
renderCardFields={(s) => {
slot.current = s;
return (
<div className="grid gap-[15px]">
<div>
<label className={s.labelClassName}>Card number</label>
<div className={s.fieldClassName}><PayPalCardNumberField style={style} containerStyles={fill} placeholder="Card number" /></div>
</div>
<div className="flex gap-[14px]">
<div className="flex-1">
<label className={s.labelClassName}>Expiry</label>
<div className={s.fieldClassName}><PayPalCardExpiryField style={style} containerStyles={fill} placeholder="MM/YY" /></div>
</div>
<div className="flex-1">
<label className={s.labelClassName}>CVV</label>
<div className={s.fieldClassName}><PayPalCardCvvField style={style} containerStyles={fill} placeholder="123" /></div>
</div>
</div>
</div>
);
}}
onConfirm={async ({ name }) => {
const outcome = new Promise<Outcome>((resolve) => (waiting.current = resolve));
await submit(createPayPalOrder(), { name }); // 3D Secure runs here when the card needs it
const { orderId, error } = await outcome;
if (!orderId) throw new Error(error);
const { completed } = await capturePayPalOrder(orderId);
if (!completed) throw new Error("The payment didn't go through. Try another card.");
setTimeout(() => router.push(`/thanks?order=${orderId}`), 1200); // let the success state play
}}
/>
);
}
export default function Checkout({ clientToken }: { clientToken: string }) {
const slot = useRef<CardFieldsSlot | null>(null);
const [message, setMessage] = useState("");
return (
<PayPalProvider
clientToken={clientToken}
environment={process.env.NEXT_PUBLIC_PAYPAL_ENV === "production" ? "production" : "sandbox"}
components={["paypal-payments", "card-fields"]}
pageType="checkout"
>
<div className="mx-auto grid w-full max-w-[440px] gap-4">
<PayPalButton onError={setMessage} />
<p role="status" className="min-h-5 text-center text-sm text-red-400">{message}</p>
<p className="text-center text-xs uppercase tracking-widest text-white/40">or pay by card</p>
<PayPalCardFieldsProvider
cardtypechange={(e) => slot.current?.onChange({ brand: brandOf(e) })}
validitychange={(e) => slot.current?.onChange({ complete: allValid(e) })}
focus={(e) => slot.current?.onChange({ focus: e.sender === "cvv" ? "cvc" : null })}
blur={() => slot.current?.onChange({ focus: null })}
>
<CardForm slot={slot} />
</PayPalCardFieldsProvider>
</div>
</PayPalProvider>
);
}
app/pay/page.tsx
import { browserSafeClientToken } from "@/lib/paypal";
import Checkout from "./checkout";
// A fresh client token for every visit, so render this page on request.
export const dynamic = "force-dynamic";
export default async function PayPage() {
const clientToken = await browserSafeClientToken();
return (
<main className="grid min-h-dvh place-items-center overflow-hidden bg-[#0b0a14] p-4">
<Checkout clientToken={clientToken} />
</main>
);
}
app/pay/actions.ts (Server Actions: create and capture the order)
"use server";
import { paypalPost } from "@/lib/paypal";
type Order = { id?: string };
type Capture = {
status?: string;
purchase_units?: { payments?: { captures?: { id: string; status: string }[] } }[];
};
// The amount is set here, on the server. Never take it from the browser.
export async function createPayPalOrder(): Promise<string> {
const { ok, data } = await paypalPost<Order>("/v2/checkout/orders", {
intent: "CAPTURE",
purchase_units: [{ amount: { currency_code: "USD", value: "10.00" } }],
});
if (!ok || !data.id) throw new Error("Couldn't start the payment.");
return data.id;
}
// Approval only means the buyer said yes. The money moves when you capture.
export async function capturePayPalOrder(orderId: string) {
if (!/^[A-Z0-9]+$/.test(orderId)) return { completed: false };
const { ok, data } = await paypalPost<Capture>(`/v2/checkout/orders/${orderId}/capture`);
const capture = data.purchase_units?.[0]?.payments?.captures?.[0];
// A capture can come back PENDING (an eCheck, a review). Ship on COMPLETED only.
return { completed: ok && data.status === "COMPLETED" && capture?.status === "COMPLETED", captureId: capture?.id };
}
lib/paypal.ts (server only: tokens and the REST calls)
import "server-only";
const BASE = process.env.PAYPAL_API_BASE ?? "https://api-m.sandbox.paypal.com"; // https://api-m.paypal.com when live
async function token(form: string) {
const basic = Buffer.from(`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`).toString("base64");
const res = await fetch(`${BASE}/v1/oauth2/token`, {
method: "POST",
headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
body: form,
cache: "no-store",
});
if (!res.ok) throw new Error(`PayPal token request failed (${res.status})`);
return ((await res.json()) as { access_token: string }).access_token;
}
/** Browser-safe token for <PayPalProvider clientToken>. */
export const browserSafeClientToken = () =>
token("grant_type=client_credentials&response_type=client_token&intent=sdk_init");
/** POST to a PayPal REST API with a full-scope token (server only). */
export async function paypalPost<T>(path: string, body?: unknown): Promise<{ ok: boolean; data: T }> {
const access = await token("grant_type=client_credentials&response_type=token");
const res = await fetch(`${BASE}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${access}`, "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
cache: "no-store",
});
return { ok: res.ok, data: (await res.json()) as T };
}
app/api/paypal/webhook/route.ts (verify the signature, then fulfil)
import { paypalPost } from "@/lib/paypal";
// PayPal signs every webhook. Ask PayPal to check the signature before you trust the event.
export async function POST(req: Request) {
const raw = await req.text();
const h = req.headers;
const { ok, data } = await paypalPost<{ verification_status?: string }>("/v1/notifications/verify-webhook-signature", {
auth_algo: h.get("paypal-auth-algo"),
cert_url: h.get("paypal-cert-url"),
transmission_id: h.get("paypal-transmission-id"),
transmission_sig: h.get("paypal-transmission-sig"),
transmission_time: h.get("paypal-transmission-time"),
webhook_id: process.env.PAYPAL_WEBHOOK_ID, // from the app's Webhooks list in the developer dashboard
webhook_event: JSON.parse(raw),
});
if (!ok || data.verification_status !== "SUCCESS") return new Response("bad signature", { status: 400 });
const event = JSON.parse(raw) as { event_type: string; resource: { id: string; status?: string } };
if (event.event_type === "PAYMENT.CAPTURE.COMPLETED") {
// Mark the order paid here. PayPal can send an event more than once: key on resource.id.
}
return new Response("ok");
}