Use it in Next.js
app/pay/checkout.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { payments, type Card, type CardClassSelectors, type CardInputEvent, type SqEvent } from "@square/web-sdk";
import CardCheckout, { type CardFieldsSlot } from "@/components/CardCheckout";
import { charge } from "./actions";
// Square styles the inside of its iframe with a fixed set of selectors and a few properties each:
// text on input, borders on .input-container, colours on the error message.
const style: CardClassSelectors = {
input: { backgroundColor: "#0d0e13", color: "#F4F4F6", fontSize: "16px" },
"input::placeholder": { color: "#4a4f61" },
".input-container": { borderColor: "#262A36", borderRadius: "14px" },
".input-container.is-focus": { borderColor: "#C6FF3D" },
".input-container.is-error": { borderColor: "#FF5C6C" },
".message-text.is-error": { color: "#FF5C6C" },
};
// Square's field names to the three the 3D card knows. The postal code keeps the front side up.
const focusOf: Record<string, "number" | "expiry" | "cvc" | null> = {
cardNumber: "number",
expirationDate: "expiry",
cvv: "cvc",
postalCode: null,
};
function SquareCard({ slot, appId, locationId, onReady }: {
slot: CardFieldsSlot;
appId: string;
locationId: string;
onReady: (card: Card | null) => void;
}) {
const box = useRef<HTMLDivElement>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let gone = false;
let card: Card | undefined;
const valid: Record<string, boolean> = { cardNumber: false, expirationDate: false, cvv: false };
// Square has no per keystroke change event. Its events fire on focus, blur, error and brand
// changes, and each one carries the field's currentState.
const track = (e: SqEvent<CardInputEvent>) => {
const { field, currentState } = e.detail;
if (field in valid) valid[field] = currentState.isCompletelyValid;
slot.onChange({ complete: valid.cardNumber && valid.expirationDate && valid.cvv });
};
(async () => {
try {
const sq = await payments(appId, locationId);
// Strict Mode runs this effect twice in dev. The first run stops here, before it builds a card.
if (!sq || gone) return;
const created = await sq.card({ style });
if (gone) return void created.destroy();
card = created;
// cardBrand is "VISA", "MASTERCARD", "AMERICAN_EXPRESS" and so on.
card.addEventListener("cardBrandChanged", (e) => slot.onChange({ brand: e.detail.cardBrand.toLowerCase() }));
card.addEventListener("focusClassAdded", (e) => slot.onChange({ focus: focusOf[e.detail.field] ?? null }));
card.addEventListener("focusClassRemoved", (e) => {
track(e);
slot.onChange({ focus: null });
});
card.addEventListener("errorClassAdded", track);
card.addEventListener("errorClassRemoved", track);
await card.attach(box.current!);
if (!gone) onReady(card);
} catch {
if (!gone) setFailed(true);
}
})();
return () => {
gone = true;
onReady(null);
void card?.destroy();
};
}, [appId, locationId, slot, onReady]);
// One iframe holds number, expiry, CVV and postal code. Square draws its own borders inside it.
return (
<div>
<label className={slot.labelClassName}>Card</label>
<div ref={box} className="min-h-[54px]" />
{failed && <p role="alert" className="text-sm text-[#FF5C6C]">The card form 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 SquareCheckout({ appId, locationId, amount }: { appId: string; locationId: string; amount: number }) {
const router = useRouter();
const { box, scale } = useFit();
const card = useRef<Card | null>(null);
const setCard = useRef((c: Card | null) => (card.current = c)).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) => <SquareCard slot={slot} appId={appId} locationId={locationId} onReady={setCard} />}
onConfirm={async ({ name }) => {
if (!card.current) throw new Error("The card form hasn't loaded yet.");
// Passing verification details lets Square run its buyer checks (SCA) inside tokenize().
const result = await card.current.tokenize({
amount: amount.toFixed(2),
currencyCode: "USD",
intent: "CHARGE",
billingContact: { givenName: name },
customerInitiated: true,
sellerKeyedIn: false,
});
if (result.status !== "OK") {
const first = "errors" in result ? result.errors[0]?.message : undefined;
throw new Error(first ?? "Check the card details and try again.");
}
const paid = await charge(result.token, crypto.randomUUID());
if (!paid.ok) throw new Error(paid.error);
setTimeout(() => router.push(`/thanks?payment=${paid.id}`), 1200); // let the success state play
}}
/>
</div>
);
}
app/pay/page.tsx
import SquareCheckout from "./checkout";
// Read the ids per request, so one build works in sandbox and production.
export const dynamic = "force-dynamic";
export default function PayPage() {
// Both ids are public: they identify your app and location, they can't charge anything.
return (
<SquareCheckout
appId={process.env.SQUARE_APPLICATION_ID!}
locationId={process.env.SQUARE_LOCATION_ID!}
amount={10}
/>
);
}
app/pay/actions.ts
"use server";
import { SquareError } from "square";
import { square } from "@/lib/square";
type Charge = { ok: true; id: string } | { ok: false; error: string };
export async function charge(token: string, idempotencyKey: string): Promise<Charge> {
try {
const { payment } = await square().payments.create({
sourceId: token,
idempotencyKey, // the same key on a retry means Square charges once
// Set on the server, never by the browser. Money amounts are bigint, in the smallest unit.
amountMoney: { amount: BigInt(1000), currency: "USD" },
locationId: process.env.SQUARE_LOCATION_ID,
});
if (payment?.id && payment.status !== "FAILED") return { ok: true, id: payment.id };
return { ok: false, error: "That card didn't go through." };
} catch (err) {
// A declined card comes back as a SquareError. errors[0].code says why, e.g. CVV_FAILURE.
if (err instanceof SquareError) return { ok: false, error: err.errors[0]?.detail ?? "That card didn't go through." };
throw err;
}
}
lib/square.ts
import { SquareClient, SquareEnvironment } from "square";
// Server only. The access token never reaches the browser.
// Built on first use, so next build doesn't need your keys.
let client: SquareClient | undefined;
export function square() {
return (client ??= new SquareClient({
token: process.env.SQUARE_ACCESS_TOKEN,
environment: process.env.SQUARE_ENV === "production" ? SquareEnvironment.Production : SquareEnvironment.Sandbox,
}));
}