Guides · September 2, 2023
Checkout Form Patterns That Convert (shadcn Forms + Zod)
Converting checkout forms combine react-hook-form and Zod for validation, shadcn/ui's Form primitives for accessible field composition, and a small set of UX rules — inline errors, smart input types, and honest loading states — that keep shoppers from abandoning at the last step.
By Polo Themes
A checkout form converts well when it validates as the shopper types instead of after they submit, when every input uses the right keyboard and autofill hints for its data, and when errors are specific enough to fix in one glance. The most reliable way to build that today in a React or Next.js storefront is react-hook-form for form state, Zod for a single schema that drives both client and server validation, and shadcn/ui's Form component to wire the two together with accessible markup out of the box. The rest of this guide walks through the exact patterns — schema design, field-level composition, address and payment fields, error and loading states — that separate a checkout form shoppers finish from one they abandon.
Checkout is the highest-stakes form on a commerce site. A shopper who hits a confusing validation error on a signup form just tries again; a shopper who hits one at checkout, holding a credit card and already annoyed by shipping costs, often just leaves. The patterns below are not stylistic preferences — each one maps to a specific, well-documented point of checkout friction: unclear errors, wrong input types breaking autofill, forms that block on network calls with no feedback, and schemas that drift out of sync between the browser and the server.
Why react-hook-form + Zod + shadcn/ui is the default stack
Three libraries, three separate jobs, and that separation is exactly why the combination holds up under real checkout complexity. react-hook-form manages form state via uncontrolled inputs and a subscription model, so a checkout form with fifteen fields does not re-render on every keystroke the way naive controlled-input forms do. Zod describes the shape of valid data — a shipping address, a card expiry, an email — as a schema you can also reuse on the server, so the same rules that block a bad submission client-side reject it server-side too. shadcn/ui is not a component library you install as a dependency; it is a set of copy-in components (Form, Input, Select, RadioGroup, and friends) built on Radix primitives and Tailwind, wired to react-hook-form's context via a FormField wrapper that handles label association, aria-describedby wiring for errors, and focus management for you.
The alternative — hand-rolling controlled inputs with useState per field and manual validation on submit — works for a two-field newsletter signup and falls apart at checkout scale. You end up re-implementing error-message association, re-render optimization, and schema reuse from scratch, usually inconsistently across the shipping, billing, and payment sections of the same form.
Step 1: Design the Zod schema first
Start with the schema, not the JSX. A checkout schema should be composed from smaller schemas — address, contact, payment metadata — so you can reuse the address shape for both shipping and billing without duplicating rules.
- Contact: email with `.email()`, plus a phone field validated with a regex or a library like libphonenumber-js rather than a naive digit-count check.
- Address: line1 required, line2 optional, city/region/postal all required, country as an enum so the postal-code rule can branch on it later with `.superRefine()`.
- Payment metadata: cardholder name and billing-address-matches-shipping toggle — actual card number and CVC fields almost always belong to a hosted, PCI-scoped element (Stripe Elements or similar) and should not enter your own Zod schema or form state at all.
- Cross-field rules: use `.refine()` or `.superRefine()` at the top-level schema for rules that span fields, such as requiring a billing address only when "same as shipping" is unchecked.
Keep the schema in its own module, separate from the form component. That is what lets you import the identical schema into a server action or API route and re-validate before charging a card or creating an order — never trust client-side validation alone for anything that touches money.
A minimal address schema
In practice this looks like a small object schema exported from something like `lib/validations/checkout.ts`: `addressSchema` with `line1`, `city`, `region`, `postalCode`, and `country` fields, each with a `.min(1, "Required")` or format check, then a parent `checkoutSchema` that nests `shippingAddress: addressSchema` and `billingAddress: addressSchema.optional()`, tied together with a `.superRefine()` that fills in or requires `billingAddress` based on a `billingSameAsShipping` boolean. Because it is plain Zod, the same file can be imported into a Next.js route handler with zero adaptation.
Step 2: Wire the schema to react-hook-form via zodResolver
The connective tissue is `@hookform/resolvers/zod`, which turns a Zod schema into a resolver react-hook-form understands. Call `useForm` with `resolver: zodResolver(checkoutSchema)` and a `defaultValues` object matching the schema's shape, then set `mode: "onTouched"` so fields validate after the shopper leaves them the first time, and re-validate on every change after that. That is the single mode setting that most affects perceived form quality: validating too eagerly (on every keystroke from the start) punishes a shopper mid-typing for an email address; validating only on submit surfaces every problem at once, at the worst possible moment.
shadcn/ui's Form component is a thin wrapper around react-hook-form's `FormProvider`, plus a `FormField` render-prop component that hands you a controller-bound field object and automatically generates the `id`, `aria-describedby`, and `aria-invalid` attributes linking a label, input, description, and error message. This is the part most hand-built forms get wrong or skip entirely — screen reader users depend on that wiring to hear "Postal code, invalid, required field" instead of just "invalid".
Step 3: Field-level patterns that actually move conversion
Match input type and inputMode to the data
Every checkout input should declare the narrowest correct `type` and `inputMode` so mobile keyboards switch automatically: `type="email"` for email, `inputMode="numeric"` for postal codes and card-adjacent numeric fields, `type="tel"` for phone. This single change reduces mistyped input more than almost any validation rule, because it prevents the wrong keyboard from appearing in the first place rather than catching the error after.
Use autoComplete tokens, not just placeholders
Browsers and password managers autofill checkout forms using the HTML `autocomplete` attribute, not field names or labels. Set `autoComplete="shipping given-name"`, `"shipping address-line1"`, `"shipping postal-code"`, `"cc-name"`, and so on per the WHATWG spec's checkout-specific tokens. A form that skips this forces every shopper to hand-type an address they've already stored in their browser — a completely avoidable source of abandonment that has nothing to do with your validation logic.
Inline errors that name the fix, not the failure
A Zod `.min(1)` error defaults to something like "String must contain at least 1 character(s)" — never ship that. Override every message explicitly: `"Enter your postal code"` instead of a length complaint, `"Enter a valid email address"` instead of a regex failure. shadcn/ui's `FormMessage` renders whatever string the resolver produces, so the quality of your error copy is entirely a schema-authoring decision, not a component one.
Debounce cross-field checks, don't debounce field-level ones
Simple field rules (required, format) should validate immediately on blur — there's no cost to it. But anything that hits the network — a postal-code-to-city lookup, a tax or shipping-rate recalculation triggered by an address change — should debounce a few hundred milliseconds so a shopper mid-typing an address doesn't trigger five wasted API calls before they finish the field.
Step 4: Handle async state honestly
A checkout submit button has at minimum three states — idle, submitting, and either success or a recoverable error — and the button itself is the cheapest, most visible place to communicate all three. Disable the button and swap its label to something like "Placing order…" the instant `onSubmit` fires, using react-hook-form's `formState.isSubmitting`, so a shopper never double-clicks and never wonders if the click registered. On failure — a declined card, a stock conflict, a network error — surface the message at the top of the form as well as inline on the specific field if one is implicated, and re-enable the button so retry is a single click, not a page reload.
Resist the temptation to reset the whole form on a failed submission. A shopper who mistyped one digit of a card number should not have to re-enter their name, email, and address because the form wiped everything on error. react-hook-form preserves field values across a failed `handleSubmit` by default — don't fight that by manually resetting state in a catch block.
Step 5: Multi-step checkout without losing form state
Many checkouts split into contact, shipping, and payment steps for perceived progress and lower per-screen cognitive load. The common mistake is mounting a separate `useForm` instance per step, which loses earlier answers the moment a shopper clicks back to fix an address. Instead, keep a single form instance at the top of the checkout flow — one schema, one `useForm` call — and render only the relevant `FormField`s per step, gating the "Continue" button on `form.trigger()` scoped to that step's field names (react-hook-form's `trigger` accepts an array of field paths to validate a subset without touching the rest). This keeps validation step-scoped for a good UX while keeping state, and eventual full-schema validation, unified.
- Store step index as local component state, not in the form values.
- Validate only the current step's fields on "Continue" via `form.trigger([...fieldNames])`.
- Run the full `checkoutSchema` once more on final submit — a shopper can still reach the last step with an earlier field left blank if they used the browser back/forward buttons.
- Show a persistent summary of completed steps (address, shipping method) so backing up to fix one thing doesn't feel like starting over.
Where headless commerce and shadcn/ui checkout patterns meet
These patterns matter most in headless setups — a Next.js storefront calling a commerce API like Medusa, Shopify's Storefront API, or a custom backend — because you own the entire checkout UI rather than inheriting a theme's built-in checkout. That ownership is the whole appeal of headless commerce: full control over field order, step structure, and validation copy, at the cost of having to build all of it correctly yourself. shadcn/ui earns its popularity here specifically because it isn't a runtime dependency with its own opinions baked in — it's copied into your repo as editable source, so a checkout form's fields, spacing, and error states can match your brand exactly instead of fighting a component library's defaults.
If your team is building the surrounding storefront visuals — product pages, collection grids, the overall commerce layout this checkout form sits inside — a well-structured Figma source file matters as much as the code, since design and implementation drift apart fast on a bespoke build. Polo Themes' Figma UI kits are built for exactly this kind of componentized, headless storefront work, with the same field- and state-level thinking (error states, loading states, empty states) modeled at the design layer before a single line of react-hook-form gets written.
A short checklist before you ship
- Single Zod schema, imported by both the form and the server-side handler that finalizes the order.
- `mode: "onTouched"` on `useForm`, not the default submit-only validation.
- Every input has a correct `type`, `inputMode`, and WHATWG `autoComplete` token.
- Every Zod error message is hand-written, not the library default.
- Submit button reflects idle/submitting/error state and never allows a double-submit.
- Card number and CVC live in a PCI-scoped hosted field, never in your own form state.
- Multi-step flows use one `useForm` instance with step-scoped `trigger()` calls, not one form per step.
Frequently Asked Questions
Should card number and CVC fields use react-hook-form too?
No. Card number, expiry, and CVC should generally live inside a hosted, PCI-DSS-scoped element (Stripe Elements, Braintree Hosted Fields, or your processor's equivalent) rendered in an iframe you don't control the DOM of. That keeps raw card data out of your JavaScript entirely. Everything around it — cardholder name, billing address, the "same as shipping" toggle — is a normal field react-hook-form and Zod handle fine.
Is client-side Zod validation enough, or do I still need server validation?
Client-side validation is a UX layer, not a security boundary. Always re-run the same Zod schema on the server before creating an order or charging a payment method — a shopper can submit directly to your API, bypassing the form entirely, and client-side checks alone will not stop malformed or malicious data.
Why use shadcn/ui instead of a form library with built-in styled components?
Because it's copied into your codebase as source rather than installed as an opaque dependency, so every field, error state, and spacing decision in your checkout form is directly editable to match your brand and your specific validation needs, without fighting another library's defaults or waiting on upstream changes.
What's the single highest-impact fix for an existing checkout form?
Correct `autoComplete` tokens and input types. It requires no new libraries or validation logic, takes an afternoon on an existing form, and directly reduces the amount of manual typing shoppers have to do — which is consistently one of the largest friction points in checkout, ahead of most copy or layout changes.