What you will build
A /checkout route with two columns on desktop: the form on the left and the order summary on the right, sticky while you scroll. On phones the summary comes first as one line with the item count and the total, and a tap folds out the lines. Submitting runs a server action: empty or bad fields come back with a message under each one and a count above the button, the cursor jumps to the first field to fix, and what was typed stays. A valid form lands on /checkout/success.
The card fields are a slot. Card numbers should go straight to your payment provider, never through your own server action, so the last section shows where Stripe's Payment Element goes. No packages are needed for the rest.
The order and the money format
Amounts are whole cents, so totals never pick up floating point leftovers, and Intl.NumberFormat turns them into money in one place.
export type LineItem = { id: string; name: string; variant: string; quantity: number; unitCents: number };
// Swap this for your cart (a cookie, your database or your commerce API). Keep the shape.
export async function getOrder() {
const items: LineItem[] = [
{ id: "tee", name: "Heavy cotton tee", variant: "Black / M", quantity: 2, unitCents: 3200 },
{ id: "cap", name: "Six panel cap", variant: "Olive", quantity: 1, unitCents: 2400 },
];
const subtotal = items.reduce((sum, item) => sum + item.quantity * item.unitCents, 0);
const shipping = subtotal >= 5000 ? 0 : 500;
return { items, subtotal, shipping, total: subtotal + shipping, currency: "USD" };
}
export type Order = Awaited<ReturnType<typeof getOrder>>;
export const money = (cents: number, currency: string) =>
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(cents / 100);getOrder is async on purpose: swap the sample lines for your cart without touching the components.
The order summary
One server component, two layouts. Phones get a native details element, so it opens and closes with no JavaScript and works with the keyboard. From the lg breakpoint the same lines show open and the box sticks to the top.
import { money, type Order } from "./order";
function Lines({ order }: { order: Order }) {
return (
<>
<ul className="divide-y divide-zinc-800">
{order.items.map((item) => (
<li key={item.id} className="flex justify-between gap-4 py-3 text-sm">
<div>
<p className="font-medium">{item.name}</p>
<p className="text-zinc-400">
{item.variant} · Qty {item.quantity}
</p>
</div>
<p className="tabular-nums">{money(item.quantity * item.unitCents, order.currency)}</p>
</li>
))}
</ul>
<dl className="mt-2 space-y-1 border-t border-zinc-800 pt-3 text-sm">
<div className="flex justify-between">
<dt className="text-zinc-400">Subtotal</dt>
<dd className="tabular-nums">{money(order.subtotal, order.currency)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-zinc-400">Shipping</dt>
<dd className="tabular-nums">{order.shipping ? money(order.shipping, order.currency) : "Free"}</dd>
</div>
<div className="flex justify-between pt-2 text-base font-semibold">
<dt>Total</dt>
<dd className="tabular-nums">{money(order.total, order.currency)}</dd>
</div>
</dl>
</>
);
}
export function OrderSummary({ order }: { order: Order }) {
const count = order.items.reduce((n, item) => n + item.quantity, 0);
return (
<aside aria-label="Order summary" className="rounded-2xl border border-zinc-800 bg-zinc-900/60 p-5 lg:sticky lg:top-6">
{/* phones: one tappable line with the total, the lines fold out */}
<details className="lg:hidden">
<summary className="flex cursor-pointer list-none items-center justify-between font-medium [&::-webkit-details-marker]:hidden">
<span>Show order ({count} items)</span>
<span className="tabular-nums">{money(order.total, order.currency)}</span>
</summary>
<div className="mt-3">
<Lines order={order} />
</div>
</details>
{/* desktop: always open, sticky beside the form */}
<div className="hidden lg:block">
<h2 className="font-semibold">Order summary</h2>
<Lines order={order} />
</div>
</aside>
);
}list-none and the webkit marker rule hide the default triangle, so the summary line has room for the total.
The server action
The action reads the fields, returns a message per bad field plus what was typed, and redirects when everything passes. It runs on the server, so nobody can skip it by editing the page.
"use server";
import { redirect } from "next/navigation";
const FIELDS = ["email", "name", "address", "city", "postal", "country"] as const;
export type CheckoutField = (typeof FIELDS)[number];
export type CheckoutState = {
errors?: Partial<Record<CheckoutField, string>>;
values?: Partial<Record<CheckoutField, string>>;
};
export async function placeOrder(_prev: CheckoutState, formData: FormData): Promise<CheckoutState> {
const v = Object.fromEntries(FIELDS.map((f) => [f, String(formData.get(f) ?? "").trim()])) as Record<CheckoutField, string>;
const errors: CheckoutState["errors"] = {};
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email)) errors.email = "Enter an email for the receipt.";
if (v.name.length < 2) errors.name = "Enter the name for delivery.";
if (v.address.length < 5) errors.address = "Enter a street address.";
if (!v.city) errors.city = "Enter a city.";
if (!/^[A-Za-z0-9 -]{3,10}$/.test(v.postal)) errors.postal = "Enter a valid postal code.";
if (!v.country) errors.country = "Choose a country.";
if (Object.keys(errors).length) return { errors, values: v };
// Take the payment here (see "Take the payment" below), then save the order.
const orderId = crypto.randomUUID().slice(0, 8).toUpperCase();
redirect(`/checkout/success?order=${orderId}`);
}Keep the rules loose: the email check only catches typos, and the postal code rule accepts letters for UK and Canadian codes. Your payment provider and your shipping service still have the final word.
The form
useActionState gives the form the action's last result and a pending flag. Each field shows its own message, points at it with aria-describedby and sets aria-invalid, which Tailwind's aria-invalid: variant turns red.
"use client";
import { useActionState, useEffect, useRef, type ReactNode } from "react";
import { placeOrder, type CheckoutField, type CheckoutState } from "./actions";
const input =
"mt-1 w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2.5 outline-none transition focus:border-lime-300 focus:ring-4 focus:ring-lime-300/20 aria-invalid:border-red-400";
function Field(props: { name: CheckoutField; label: string; state: CheckoutState; autoComplete: string; type?: string; className?: string }) {
const { name, label, state, autoComplete, type = "text", className } = props;
const error = state.errors?.[name];
return (
<div className={className}>
<label htmlFor={name} className="text-sm font-medium">{label}</label>
<input
id={name}
name={name}
type={type}
autoComplete={autoComplete}
defaultValue={state.values?.[name]}
aria-invalid={!!error}
aria-describedby={error ? `${name}-error` : undefined}
className={input}
/>
{error && <p id={`${name}-error`} className="mt-1 text-sm text-red-400">{error}</p>}
</div>
);
}
export function CheckoutForm({ total, payment }: { total: string; payment: ReactNode }) {
const [state, action, pending] = useActionState(placeOrder, {});
const form = useRef<HTMLFormElement>(null);
const count = Object.keys(state.errors ?? {}).length;
// after a failed submit, put the cursor in the first field that needs fixing
useEffect(() => {
form.current?.querySelector<HTMLElement>("[aria-invalid=true]")?.focus();
}, [state]);
return (
<form ref={form} action={action} noValidate className="space-y-8">
<fieldset>
<legend className="mb-3 text-lg font-semibold">Contact</legend>
<Field name="email" label="Email" type="email" autoComplete="email" state={state} />
</fieldset>
<fieldset>
<legend className="mb-3 text-lg font-semibold">Delivery</legend>
<div className="grid gap-4 sm:grid-cols-2">
<Field name="name" label="Full name" autoComplete="shipping name" state={state} className="sm:col-span-2" />
<Field name="address" label="Address" autoComplete="shipping street-address" state={state} className="sm:col-span-2" />
<Field name="city" label="City" autoComplete="shipping address-level2" state={state} />
<Field name="postal" label="Postal code" autoComplete="shipping postal-code" state={state} />
<div className="sm:col-span-2">
<label htmlFor="country" className="text-sm font-medium">Country</label>
<select
id="country"
name="country"
autoComplete="shipping country"
defaultValue={state.values?.country ?? ""}
aria-invalid={!!state.errors?.country}
aria-describedby={state.errors?.country ? "country-error" : undefined}
className={input}
>
<option value="" disabled>Choose...</option>
<option value="US">United States</option>
<option value="GB">United Kingdom</option>
<option value="DE">Germany</option>
<option value="RO">Romania</option>
</select>
{state.errors?.country && <p id="country-error" className="mt-1 text-sm text-red-400">{state.errors.country}</p>}
</div>
</div>
</fieldset>
<fieldset>
<legend className="mb-3 text-lg font-semibold">Payment</legend>
{payment}
</fieldset>
<p role="alert" className="min-h-5 text-sm text-red-400">
{count > 0 && `Check the ${count === 1 ? "highlighted field" : `${count} highlighted fields`}.`}
</p>
<button
type="submit"
disabled={pending}
className="w-full rounded-xl bg-lime-300 px-4 py-3.5 font-semibold text-zinc-950 transition active:scale-[0.98] disabled:opacity-60"
>
{pending ? "Processing..." : `Pay ${total}`}
</button>
</form>
);
}The autoComplete values (email, shipping street-address, shipping postal-code and so on) let browsers and password managers fill the whole form in one tap. While the action runs the button says Processing... and is disabled, so a double tap cannot send two orders. The role="alert" line reads the error count to screen reader users.
The page and the success screen
The page loads the order on the server and passes the formatted total to the button. On phones the summary comes first in the markup, and lg:order-2 moves it to the right column on desktop.
import type { Metadata } from "next";
import { CheckoutForm } from "./CheckoutForm";
import { OrderSummary } from "./OrderSummary";
import { getOrder, money } from "./order";
export const metadata: Metadata = { title: "Checkout", robots: { index: false } };
export default async function CheckoutPage() {
const order = await getOrder();
return (
<div className="mx-auto grid max-w-5xl gap-8 px-5 py-10 lg:grid-cols-[1fr_380px] lg:items-start">
<div className="lg:order-2">
<OrderSummary order={order} />
</div>
<div className="lg:order-1">
<h1 className="mb-6 text-2xl font-semibold">Checkout</h1>
<CheckoutForm
total={money(order.total, order.currency)}
payment={<p className="rounded-lg border border-dashed border-zinc-700 p-4 text-sm text-zinc-400">Card fields from your payment provider go here.</p>}
/>
</div>
</div>
);
}The success page reads the order number from the URL and stamps PAID onto the card.
export default async function SuccessPage({ searchParams }: { searchParams: Promise<{ order?: string }> }) {
const { order } = await searchParams;
return (
<div className="grid min-h-[70dvh] place-items-center px-5">
<div className="relative w-full max-w-sm rounded-2xl border border-zinc-800 bg-zinc-900/60 p-6 text-center">
<span
aria-hidden="true"
className="absolute -top-4 -right-3 rotate-[-12deg] rounded-md border-2 border-lime-300 bg-zinc-950 px-2 py-0.5 font-mono text-sm font-bold tracking-widest text-lime-300 motion-safe:animate-[stamp_.45s_cubic-bezier(.3,1.4,.5,1)_both]"
>
PAID
</span>
{order && <p className="text-sm text-zinc-400">Order {order}</p>}
<h1 className="mt-1 text-2xl font-semibold">Thanks, your order is in</h1>
<p className="mt-2 text-sm text-zinc-400">The receipt is on its way to your inbox.</p>
</div>
</div>
);
}Add the keyframe to your global CSS. motion-safe: plays it only for people who have not asked for reduced motion. The stamp uses Tailwind v4's rotate- utility, which sets the separate rotate property, so the scale in the keyframe does not cancel the tilt.
@keyframes stamp {
from { opacity: 0; transform: scale(2.2); }
to { opacity: 1; transform: scale(1); }
}Take the payment
The payment slot is where your provider's fields go. With Stripe, create a PaymentIntent on the server for order.total, pass its client secret to Elements, and render the Payment Element in the slot:
// app/checkout/PaymentFields.tsx, rendered inside <Elements stripe={stripePromise} options={{ clientSecret }}>
"use client";
import { PaymentElement } from "@stripe/react-stripe-js";
export function PaymentFields() {
return <PaymentElement options={{ layout: "tabs" }} />;
}When the delivery details pass, confirm the payment in the browser and let Stripe send the buyer back to the success page. Save the order from Stripe's webhook, not from the success page, because people close tabs:
// in your client submit handler, with stripe = useStripe() and elements = useElements()
const { error } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: `${window.location.origin}/checkout/success` },
});
if (error) setPaymentError(error.message ?? "The payment did not go through.");If you would rather not host card fields at all, Stripe Checkout or your shop platform's hosted checkout replaces the slot with a redirect, and this page becomes the review step before it. We did not run this section here, because it needs your Stripe keys.
What we checked
In Chrome at 390px and 1440px, in a Next.js 15.5 app with React 19 and Tailwind v4: an empty submit marked 6 fields, showed "Check the 6 highlighted fields." and put the cursor in Email. With email and name filled, 4 were left, the email stayed typed in and the cursor moved to Address. With every field filled the button read Processing..., then the page landed on /checkout/success with an order number and the stamp settled at full size. On phones the summary came first and folded out its 2 lines; at 1440px it sat in the right column. With reduced motion on, the stamp did not animate.
Using Cursor, v0 or Lovable
Cursor: paste the files and ask it to replace getOrder with your cart. If it moves the validation into the client, ask it to keep the server action and only add client checks on top.
v0: v0 often draws a checkout with card number inputs inside the same form. Ask it to keep the payment slot and put Stripe's Payment Element there, so card numbers never reach your server.
Lovable: Lovable apps are Vite with no server actions, so keep the layout, the summary and the form markup, and run the same checks in a Supabase Edge Function or your API before you create the payment.
Or use the ready-made piece
If you want the checkout to be the moment people remember, Receipt Morph Checkout is a full dark checkout page with a live card that types along, shows the brand and flips for the CVC. On pay the card lifts while a lime arc closes around it, then it splits and unrolls into a paper receipt where the items tick in and a PAID stamp slams down. The inputs are real, with a Luhn check and decline handling.
Everything above is free to copy. The piece is for when you want the finished, animated version with its source and a PROMPT.md for Cursor, v0 and Lovable.
Questions
- How do I build a checkout page in Next.js?
- Render the order on the server, put a delivery form next to it that submits to a server action, and give your payment provider's fields their own slot. useActionState shows the action's errors and a pending state for the pay button.
- Should card numbers go through a Next.js server action?
- No. Let your payment provider's fields, like Stripe's Payment Element, send card details straight to the provider. Your server only creates the payment and reads the result.
- How do I stop a checkout form from submitting twice?
- Disable the submit button while the action runs. useActionState returns a pending flag for exactly this.
- How do I make the order summary collapsible on mobile?
- Wrap it in a details element with lg:hidden and show an always open copy with hidden lg:block. details opens with no JavaScript and works with the keyboard.