app/pay/actions.ts (ui_mode elements)
"use server";
import { stripe } from "@/lib/stripe";
// ui_mode "elements" (it was "custom" before 2026-03-25): your page, your layout, Stripe's fields.
export async function createElementsSession() {
const session = await stripe.checkout.sessions.create({
ui_mode: "elements",
mode: "payment",
line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
// Used only when a payment method has to leave your page (a bank or wallet redirect).
return_url: `${process.env.SITE_URL}/checkout/return?session_id={CHECKOUT_SESSION_ID}`,
});
return session.client_secret!;
}
app/pay/page.tsx
import { ReceiptCheckout } from "./receipt-checkout";
import { createElementsSession } from "./actions";
export const dynamic = "force-dynamic"; // a fresh Checkout Session per visit, never one baked in at build
export default async function PayPage() {
const clientSecret = await createElementsSession();
return (
<main className="min-h-dvh bg-[#0b0b0f]">
<ReceiptCheckout clientSecret={clientSecret} />
</main>
);
}
app/pay/receipt-checkout.tsx (Receipt Morph Checkout)
"use client";
import { useRef } from "react";
import { useRouter } from "next/navigation";
import { loadStripe, type Appearance } from "@stripe/stripe-js";
import {
CheckoutElementsProvider,
ContactDetailsElement,
PaymentElement,
useCheckoutElements,
} from "@stripe/react-stripe-js/checkout";
import { CheckoutReceiptMorph } from "@/components/CheckoutReceiptMorph";
import type { NorthwindData } from "@/data/northwind";
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
// Stripe's fields are iframes and can't read the piece's CSS, so hand them its colours.
const appearance: Appearance = {
theme: "night",
variables: {
colorPrimary: "#8B6CFF",
colorBackground: "#16171F",
colorText: "#F4F4F6",
colorDanger: "#FF6A3D",
borderRadius: "12px",
},
};
export function ReceiptCheckout({ clientSecret }: { clientSecret: string }) {
return (
<CheckoutElementsProvider stripe={stripePromise} options={{ clientSecret, elementsOptions: { appearance } }}>
<ReceiptPay />
</CheckoutElementsProvider>
);
}
function ReceiptPay() {
const router = useRouter();
const state = useCheckoutElements();
const done = useRef({ email: false, payment: false });
if (state.type === "loading") return <div className="mx-auto h-[640px] max-w-5xl animate-pulse rounded-2xl bg-white/5" />;
if (state.type === "error") return <p role="alert">{state.error.message}</p>;
const { checkout } = state;
// The receipt reads the real session. Stripe amounts are minor units; the divisor is 100 for USD.
const unit = checkout.minorUnitsAmountDivisor;
const order: NorthwindData["store"] = {
name: checkout.businessName ?? "Your order",
orderId: checkout.id.slice(-8),
items: checkout.lineItems.map((li) => ({
name: li.name,
variant: li.description ?? "",
qty: li.quantity,
price: li.unitAmount.minorUnitsAmount / unit,
})),
shipping: checkout.total.shippingRate.minorUnitsAmount / unit,
tax: checkout.total.taxExclusive.minorUnitsAmount / unit,
total: checkout.total.total.minorUnitsAmount / unit,
};
return (
<CheckoutReceiptMorph
order={order}
autoplayDemo={false}
renderCardFields={(slot) => {
// Pay unlocks once Stripe says both the email and the payment details are complete.
const report = () => slot.onChange({ complete: done.current.email && done.current.payment });
return (
<div className="grid gap-3">
<ContactDetailsElement onChange={(e) => { done.current.email = e.complete; report(); }} />
<PaymentElement
options={{ layout: "tabs" }}
onChange={(e) => { done.current.payment = e.complete; report(); }}
/>
</div>
);
}}
onConfirm={async () => {
// Cards confirm on this page, so the receipt animation is the success state.
const result = await checkout.confirm({ redirect: "if_required" });
return result.type === "error" ? { ok: false, message: result.error.message } : { ok: true };
}}
onComplete={() => setTimeout(() => router.push(`/checkout/return?session_id=${checkout.id}`), 2500)}
/>
);
}