The checkout page
app/checkout/page.tsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { CheckoutForm } from "@/components/checkout-form";
import { getCart } from "@/lib/cart";
function money(amount: number, currency: string) {
const fmt = new Intl.NumberFormat("en", { style: "currency", currency });
return fmt.format(amount / 10 ** (fmt.resolvedOptions().maximumFractionDigits ?? 2));
}
export default async function CheckoutPage() {
const cart = await getCart();
return (
<main className="mx-auto grid max-w-4xl gap-6 px-4 py-10 md:grid-cols-[1fr_380px]">
<Card>
<CardHeader>
<CardTitle>Payment</CardTitle>
<CardDescription>Card, wallets and local methods, as enabled in your Stripe Dashboard.</CardDescription>
</CardHeader>
<CardContent>
<CheckoutForm amount={cart.total} currency={cart.currency} />
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle>Order summary</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm">
{cart.items.map((item) => (
<div key={item.id} className="flex justify-between gap-4">
<span>
{item.name} <span className="text-muted-foreground">x {item.quantity}</span>
</span>
<span className="tabular-nums">{money(item.amount * item.quantity, cart.currency)}</span>
</div>
))}
<div className="flex justify-between border-t pt-3 font-medium">
<span>Total</span>
<span className="tabular-nums">{money(cart.total, cart.currency)}</span>
</div>
</CardContent>
</Card>
</main>
);
}
components/checkout-form.tsx (Payment Element + shadcn Button)
"use client";
import { useEffect, useState, type FormEvent } from "react";
import { loadStripe, type Appearance } from "@stripe/stripe-js";
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
import { Button } from "@/components/ui/button";
import { shadcnAppearance } from "@/lib/stripe-appearance";
import { createPaymentIntent } from "@/app/checkout/actions";
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
export function CheckoutForm({ amount, currency }: { amount: number; currency: string }) {
const [appearance, setAppearance] = useState<Appearance>();
useEffect(() => setAppearance(shadcnAppearance()), []); // tokens are only readable in the browser
if (!appearance) return <div className="h-56 animate-pulse rounded-md bg-muted" />;
return (
// No PaymentIntent yet: Elements renders from the amount, the intent is made on submit.
<Elements stripe={stripePromise} options={{ mode: "payment", amount, currency, appearance }}>
<PayForm />
</Elements>
);
}
function PayForm() {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState<string>();
const [busy, setBusy] = useState(false);
async function onSubmit(e: FormEvent) {
e.preventDefault();
if (!stripe || !elements) return;
setBusy(true);
setError(undefined);
const { error: fieldError } = await elements.submit(); // validates the fields first
if (fieldError) {
setError(fieldError.message);
setBusy(false);
return;
}
const { clientSecret } = await createPaymentIntent();
const { error: payError } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: { return_url: `${window.location.origin}/checkout/complete` },
});
// Only reached on an error: on success Stripe sends the buyer to return_url.
setError(payError?.message);
setBusy(false);
}
return (
<form onSubmit={onSubmit} className="grid gap-4">
<PaymentElement options={{ layout: "tabs" }} />
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" size="lg" disabled={!stripe || busy}>
{busy ? "Paying..." : "Pay now"}
</Button>
</form>
);
}
lib/stripe-appearance.ts (shadcn tokens to Stripe's Appearance API)
"use client";
import type { Appearance } from "@stripe/stripe-js";
// The Payment Element renders inside Stripe's iframe, so it can't read your CSS variables.
// Resolve shadcn's tokens to plain rgb() strings in the browser and hand those to Stripe.
function token(name: string) {
const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
if (!raw) return undefined;
// shadcn's Tailwind v3 themes store bare HSL parts ("222.2 47.4% 11.2%"); v4 themes store oklch(...).
const color = raw.includes("(") || raw.startsWith("#") ? raw : `hsl(${raw})`;
const ctx = document.createElement("canvas").getContext("2d");
if (!ctx) return undefined;
ctx.fillStyle = color;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
return a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(2)})`;
}
export function shadcnAppearance(): Appearance {
const radius = getComputedStyle(document.documentElement).getPropertyValue("--radius").trim() || "0.625rem";
return {
theme: "stripe",
variables: {
colorPrimary: token("--primary"),
colorBackground: token("--background"),
colorText: token("--foreground"),
colorTextSecondary: token("--muted-foreground"),
colorDanger: token("--destructive"),
borderRadius: radius,
fontFamily: "system-ui, sans-serif", // the iframe can't see next/font; pass a font via Elements' fonts option if you need yours
},
rules: {
".Input": { borderColor: token("--input") ?? "", boxShadow: "none" },
".Input:focus": { borderColor: token("--ring") ?? "", boxShadow: `0 0 0 3px ${token("--ring") ?? "transparent"}` },
},
};
}
app/checkout/actions.ts (Server Action, creates the PaymentIntent)
"use server";
import Stripe from "stripe";
import { getCart } from "@/lib/cart";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Runs on the server when the buyer presses Pay. The amount comes from your cart, never from the browser.
export async function createPaymentIntent() {
const cart = await getCart();
const intent = await stripe.paymentIntents.create({
amount: cart.total, // in the currency's smallest unit (cents for USD)
currency: cart.currency,
automatic_payment_methods: { enabled: true },
metadata: { cartId: cart.id },
});
return { clientSecret: intent.client_secret! };
}