Use it in Next.js
app/pay/checkout.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { client, hostedFields, type HostedFields, type HostedFieldsEvent } from "braintree-web";
import CardCheckout, { type CardFieldsSlot } from "@/components/CardCheckout";
import { charge } from "./actions";
// Hosted Fields style the input inside each iframe, and only these CSS properties
// (color, font, letter-spacing, line-height, padding, opacity, transition and a few more).
// Borders, background and height belong to your own wrapper div.
const styles = {
input: { color: "#F4F4F6", "font-size": "19px", "font-family": "ui-monospace, monospace" },
"::placeholder": { color: "#4a4f61" },
".invalid": { color: "#FF5C6C" },
};
// One card left in e.cards means Braintree knows the brand. The 3D card draws Visa and Mastercard.
const brandOf = (e: HostedFieldsEvent) => {
const type = e.cards.length === 1 ? e.cards[0].type : null;
return type === "master-card" ? "mastercard" : type;
};
const focusOf = { number: "number", expirationDate: "expiry", cvv: "cvc" } as const;
const allValid = (e: HostedFieldsEvent) =>
e.fields.number.isValid && e.fields.expirationDate.isValid && e.fields.cvv.isValid;
function BraintreeFields({ slot, authorization, onReady }: {
slot: CardFieldsSlot;
authorization: string;
onReady: (hf: HostedFields | null) => void;
}) {
const number = useRef<HTMLDivElement>(null);
const expiry = useRef<HTMLDivElement>(null);
const cvv = useRef<HTMLDivElement>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let gone = false;
let hf: HostedFields | undefined;
(async () => {
try {
const bt = await client.create({ authorization });
// Strict Mode runs this effect twice in dev. The first run is cleaned up before
// client.create resolves, so it stops here and never adds a second set of iframes.
if (gone) return;
const created = await hostedFields.create({
client: bt,
styles,
fields: {
number: { container: number.current!, placeholder: "4111 1111 1111 1111" },
expirationDate: { container: expiry.current!, placeholder: "MM/YY" },
cvv: { container: cvv.current!, placeholder: "123" },
},
});
if (gone) return void created.teardown();
hf = created;
hf.on("cardTypeChange", (e) => slot.onChange({ brand: brandOf(e) }));
hf.on("validityChange", (e) => slot.onChange({ complete: allValid(e) }));
hf.on("focus", (e) => {
const f = e.emittedBy;
slot.onChange({ focus: f === "number" || f === "expirationDate" || f === "cvv" ? focusOf[f] : null });
});
hf.on("blur", () => slot.onChange({ focus: null }));
onReady(hf);
} catch {
if (!gone) setFailed(true);
}
})();
return () => {
gone = true;
onReady(null);
void hf?.teardown();
};
}, [authorization, slot, onReady]);
// Braintree adds braintree-hosted-fields-invalid to the wrapper, so the border can turn red.
const box = `${slot.fieldClassName} [&.braintree-hosted-fields-invalid]:border-[#FF5C6C]`;
return (
<div className="grid gap-[15px]">
<div>
<label className={slot.labelClassName}>Card number</label>
<div ref={number} className={box} />
</div>
<div className="flex gap-[14px]">
<div className="flex-1">
<label className={slot.labelClassName}>Expiry</label>
<div ref={expiry} className={box} />
</div>
<div className="flex-1">
<label className={slot.labelClassName}>CVV</label>
<div ref={cvv} className={box} />
</div>
</div>
{failed && <p role="alert" className="text-sm text-[#FF5C6C]">The card fields didn't load. Refresh to try again.</p>}
</div>
);
}
// The checkout is a fixed 1000 x 660 scene. Scale it down to the width you give it, as the README says.
function useFit() {
const box = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
useEffect(() => {
const ro = new ResizeObserver(([entry]) => setScale(Math.min(1, entry.contentRect.width / 1000)));
ro.observe(box.current!);
return () => ro.disconnect();
}, []);
return { box, scale };
}
export default function BraintreeCheckout({ authorization, amount }: { authorization: string; amount: number }) {
const router = useRouter();
const { box, scale } = useFit();
const fields = useRef<HostedFields | null>(null);
const setFields = useRef((hf: HostedFields | null) => (fields.current = hf)).current;
return (
<div ref={box} className="w-full max-w-[1000px] overflow-hidden" style={{ height: 660 * scale }}>
<CardCheckout
className="origin-top-left"
style={{ transform: `scale(${scale})` }}
amount={amount}
currency="USD"
resetAfter={null}
renderCardFields={(slot) => <BraintreeFields slot={slot} authorization={authorization} onReady={setFields} />}
onConfirm={async ({ name }) => {
if (!fields.current) throw new Error("The card fields haven't loaded yet.");
// tokenize() turns the iframes' contents into a one-time nonce. No card digits reach your code.
const { nonce } = await fields.current.tokenize({ cardholderName: name });
const result = await charge(nonce);
if (!result.ok) throw new Error(result.error);
setTimeout(() => router.push(`/thanks?tx=${result.id}`), 1200); // let the success state play
}}
/>
</div>
);
}
app/pay/page.tsx
import { gateway } from "@/lib/braintree";
import BraintreeCheckout from "./checkout";
export const dynamic = "force-dynamic";
export default async function PayPage() {
// A fresh client token for each visit. It authorizes the browser, it can't charge anything.
const { clientToken } = await gateway().clientToken.generate({});
return <BraintreeCheckout authorization={clientToken} amount={10} />;
}
app/pay/actions.ts
"use server";
import { gateway } from "@/lib/braintree";
type Charge = { ok: true; id: string } | { ok: false; error: string };
export async function charge(nonce: string): Promise<Charge> {
// The amount is set here on the server, never by the browser.
const result = await gateway().transaction.sale({
amount: "10.00",
paymentMethodNonce: nonce,
options: { submitForSettlement: true },
});
if (result.success) return { ok: true, id: result.transaction.id };
// result.message holds the processor's or the validation error's text.
return { ok: false, error: result.message || "That card didn't go through." };
}
lib/braintree.ts
import braintree from "braintree";
// Server only. The private key never reaches the browser.
// Built on first use: the constructor throws when a key is missing, and next build loads
// this module while it collects page data, often before your keys exist.
let instance: braintree.BraintreeGateway | undefined;
export function gateway() {
return (instance ??= new braintree.BraintreeGateway({
environment: process.env.BRAINTREE_ENV === "production" ? braintree.Environment.Production : braintree.Environment.Sandbox,
merchantId: process.env.BRAINTREE_MERCHANT_ID!,
publicKey: process.env.BRAINTREE_PUBLIC_KEY!,
privateKey: process.env.BRAINTREE_PRIVATE_KEY!,
}));
}