Developing Single-Page Checkout for E-commerce
Single-page checkout places all order stages on one screen — no step transitions. Reduces HTTP requests, eliminates data loss on browser navigation, shortens perceived completion time. Average conversion 10–25% higher vs multi-step on mobile. Takes 5–8 business days.
Screen Layout
Standard single-page checkout layout:
┌─────────────────────────────┬──────────────────────┐
│ Contacts │ │
│ Shipping address │ Order composition │
│ Shipping method │ Promo code │
│ Payment method │ Total amount │
│ [Place order] │ │
└─────────────────────────────┴──────────────────────┘
Mobile — single column, right panel scrolls up after composition. Sticky "Place order" button fixed bottom.
Dynamic Right Panel Updates
Right panel recalculated on each change: shipping selection changes total, coupon entry changes discount. Debounced reactivity:
const { shipping, coupon } = useCheckoutStore();
const { data: summary } = useQuery({
queryKey: ['checkout-summary', shipping?.id, coupon],
queryFn: () => api.post('/checkout/summary', { shipping_id: shipping?.id, coupon }),
staleTime: 30_000,
enabled: !!shipping,
});
Server request only on real changes, not keystroke in address field.
Conditional Field Visibility
Fields appear as previous blocks fill. Logic managed by form state:
const { watch } = useFormContext();
const email = watch('contact.email');
const addressFilled = watch(['address.city', 'address.street', 'address.house'])
.every(Boolean);
return (
<>
<ContactBlock />
{email && <AddressBlock />}
{addressFilled && <ShippingBlock />}
{selectedShipping && <PaymentBlock />}
</>
);
Reduces cognitive load — user doesn't see whole screen, fills step-by-step.
Validation Without Blocking
In single-page checkout important not to block "Place order" until fully filled — creates dead-end feeling. Instead:
- Fields validated on
onBlur, notonChange - Button always active
- On click
trigger()from React Hook Form, highlights unfilled fields, page scrolls to first error
const handleSubmit = async () => {
const valid = await form.trigger();
if (!valid) {
const firstError = Object.keys(form.formState.errors)[0];
document.querySelector(`[name="${firstError}"]`)?.scrollIntoView({ behavior: 'smooth' });
return;
}
await placeOrder(form.getValues());
};
Auto-Fill from Profile
For auth users form pre-fills from profile:
useEffect(() => {
if (user) {
form.reset({
contact: { email: user.email, phone: user.phone },
address: user.defaultAddress ?? {},
});
}
}, [user]);
Multiple saved addresses — show dropdown "Select address", clicking fills fields.
Inline Shipping Selection
Shipping methods display as cards with carrier icon, price, timeline. Switching immediately updates right panel. If address not entered yet — skeleton placeholders with "from X ₽".
<RadioGroup value={selectedShipping?.id} onValueChange={handleShippingChange}>
{shippingOptions.map(option => (
<RadioGroupItem key={option.id} value={option.id}>
<span>{option.carrier_name}</span>
<span>{option.estimated_days} days</span>
<span className="font-bold">{option.price === 0 ? 'Free' : `${option.price} ₽`}</span>
</RadioGroupItem>
))}
</RadioGroup>
Built-In Payment Forms
For bank cards use payment provider JS SDK (ЮKassa, Tinkoff, CloudPayments). Card form — provider iframe inside checkout, no redirect:
const widget = new cp.CloudPayments();
widget.pay('charge', {
publicId: PUBLIC_ID,
amount: summary.total,
currency: 'RUB',
invoiceId: order.id,
email: form.getValues('contact.email'),
}, {
onSuccess: (options) => router.push(`/orders/${order.id}/confirmation`),
onFail: (reason) => toast.error(`Payment failed: ${reason}`),
});
Performance
Single-page heavier — all blocks mounted immediately. Optimizations:
- Code splitting payment SDK — loads only on "Card" selection
- Lazy import card, address components not needed first render
-
Preconnect to DaData and payment provider APIs in
<head>
Target time to interactive — under 2 seconds on 4G.
Progress Saving
Form data saved to sessionStorage via Zustand persist. If user accidentally closed tab — on return form recovers. Session TTL — 2 hours, then draft deleted.







