Use it in Next.js
app/pay/checkout.tsx
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { loadStripe, type Appearance, type StripeAddressElementOptions } from "@stripe/stripe-js";
import { Elements, AddressElement, CardNumberElement, CardExpiryElement, CardCvcElement, useStripe, useElements } from "@stripe/react-stripe-js";
import CardCheckout, { type CardFieldsSlot } from "@/components/CardCheckout";
import { createPaymentIntent } from "./actions";
import { plan } from "./plan";
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
const mapsKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY;
// The Address Element follows the Appearance API. Dark inputs to sit next to the card.
const appearance: Appearance = {
theme: "night",
variables: { colorBackground: "#12111F", colorText: "#F4F4F6", borderRadius: "12px", fontSizeBase: "15px" },
rules: {
".Label": { color: "#8A8FA3", fontSize: "13px" },
".Input": { border: "1px solid #262A36", boxShadow: "none" },
".Input:focus": { border: "1px solid #C6FF3D", boxShadow: "0 0 0 3px rgba(198, 255, 61, 0.2)" },
},
};
// The split card fields don't read appearance. They take the older style option.
const cardStyle = { base: { color: "#F4F4F6", fontFamily: "JetBrains Mono, monospace", fontSize: "18px", "::placeholder": { color: "#6E6A8A" } } };
const addressOptions: StripeAddressElementOptions = {
mode: "shipping",
allowedCountries: ["US", "CA", "GB", "DE", "FR"], // keep in sync with SHIP_TO in actions.ts
fields: { phone: "always" },
validation: { phone: { required: "never" } },
// Stripe's own Maps key only covers the Payment Element (or a Link session), not split card fields.
autocomplete: mapsKey ? { mode: "google_maps_api", apiKey: mapsKey } : { mode: "disabled" },
};
// The checkout piece draws on a fixed 1000 x 660 stage. This scales it to the column on phones.
function Fit({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => setScale(Math.min(1, entry.contentRect.width / 1000)));
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<div ref={ref} className="w-full min-w-0 overflow-hidden" style={{ height: 660 * scale }}>
<div style={{ width: 1000, height: 660, transform: `scale(${scale})`, transformOrigin: "0 0" }}>{children}</div>
</div>
);
}
function PayForm() {
const stripe = useStripe();
const elements = useElements();
const router = useRouter();
const done = useRef({ number: false, expiry: false, cvc: false }).current;
const [addressError, setAddressError] = useState<string | null>(null);
const cardFields = (slot: CardFieldsSlot) => {
const track = (k: keyof typeof done) => (e: { complete: boolean; brand?: string }) => {
done[k] = e.complete;
slot.onChange({ complete: done.number && done.expiry && done.cvc, ...(e.brand ? { brand: e.brand } : {}) });
};
return (
<div className="grid gap-[15px]">
<div>
<label className={slot.labelClassName}>Card number</label>
<div className={slot.fieldClassName}><CardNumberElement options={{ style: cardStyle }} onChange={track("number")} /></div>
</div>
<div className="flex gap-[14px]">
<div className="flex-1">
<label className={slot.labelClassName}>Expiry</label>
<div className={slot.fieldClassName}><CardExpiryElement options={{ style: cardStyle }} onChange={track("expiry")} /></div>
</div>
<div className="flex-1">
<label className={slot.labelClassName}>CVC</label>
<div className={slot.fieldClassName}>
<CardCvcElement
options={{ style: cardStyle }}
onChange={track("cvc")}
onFocus={() => slot.onChange({ focus: "cvc" })} // flips the card
onBlur={() => slot.onChange({ focus: null })}
/>
</div>
</div>
</div>
</div>
);
};
return (
<div className="mx-auto grid w-full max-w-[1000px] gap-6">
{/* The checkout piece has no address area, so the Address Element sits above it in the same <Elements>. */}
<section className="rounded-[20px] border border-[#262A36] bg-[#12111F] p-5">
<h2 className="m-0 mb-4 text-[16px] font-semibold text-[#F4F4F6]">Shipping address</h2>
<AddressElement
options={addressOptions}
// A wrong key or a blocked script ends here. Without a handler Stripe.js only logs it.
onLoadError={({ error }) => setAddressError(error.message ?? "The address form couldn't load.")}
/>
{addressError && <p role="alert" className="m-0 mt-3 text-[14px] text-[#FF5C6C]">{addressError}</p>}
</section>
<Fit>
<CardCheckout
amount={plan.amount / 100}
currency="USD"
productName={plan.name}
resetAfter={null}
renderCardFields={cardFields}
// Pay was pressed and Stripe says the three card fields are complete.
onConfirm={async ({ name }) => {
if (!stripe || !elements) throw new Error("Stripe hasn't loaded yet.");
const address = elements.getElement(AddressElement);
const card = elements.getElement(CardNumberElement);
if (!address || !card) throw new Error("Checkout fields missing.");
// getValue() also marks incomplete fields inside the Address Element.
const { complete, value } = await address.getValue();
if (!complete) throw new Error("Add a complete shipping address first.");
const res = await createPaymentIntent(value);
if ("error" in res) throw new Error(res.error);
const { error, paymentIntent } = await stripe.confirmCardPayment(res.clientSecret, {
payment_method: { card, billing_details: { name } }, // the name typed on the card
});
if (error) throw error; // the card shows Stripe's message and Pay comes back
setTimeout(() => router.push(`/thanks?pi=${paymentIntent.id}`), 1200);
}}
/>
</Fit>
</div>
);
}
export default function Checkout() {
// Deferred mode: no PaymentIntent yet, so Elements gets the amount and currency up front.
return (
<Elements stripe={stripePromise} options={{ mode: "payment", amount: plan.amount, currency: plan.currency, appearance }}>
<PayForm />
</Elements>
);
}
app/pay/actions.ts
"use server";
import Stripe from "stripe";
import type { StripeAddressElementChangeEvent } from "@stripe/stripe-js";
import { plan } from "./plan";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Same countries as allowedCountries in the browser. The browser can be edited, so check again here.
const SHIP_TO = ["US", "CA", "GB", "DE", "FR"];
// The value getValue() returns from the Address Element.
type Shipping = StripeAddressElementChangeEvent["value"];
export async function createPaymentIntent(shipping: Shipping): Promise<{ clientSecret: string } | { error: string }> {
if (!SHIP_TO.includes(shipping.address.country)) return { error: "We don't ship to that country yet." };
const intent = await stripe.paymentIntents.create({
amount: plan.amount, // set on the server, so a buyer can't change it
currency: plan.currency,
automatic_payment_methods: { enabled: true }, // the card fields confirm with confirmCardPayment
shipping: {
name: shipping.name,
phone: shipping.phone,
address: { ...shipping.address, line2: shipping.address.line2 ?? undefined },
},
});
return { clientSecret: intent.client_secret! };
}
app/pay/plan.ts
// What the buyer pays, in the smallest currency unit. The server action reads it; the browser only displays it.
export const plan = { name: "Pro plan", amount: 2900, currency: "usd" } as const;
app/pay/page.tsx
import Checkout from "./checkout";
// No PaymentIntent is created on page load. The server action makes one when the buyer pays.
export default function PayPage() {
return (
<main className="min-h-dvh bg-[#0b0a14] p-4">
<Checkout />
</main>
);
}