Blog · · 12 min
Vibe coding a store: what breaks at checkout
Six things break between the code running and somebody paying: the cart forgets itself on reload, shipping appears at the last step, a rejected field cannot be corrected, the pay button submits twice, there is no order summary, and the VAT is wrong. Each one has a small fix and none of them shows up in local testing.
By uxgen
The store runs. Nobody pays. The gap is almost always the same six things, and none of them look like bugs while you are testing.
The cart empties itself when the page reloads. The shipping cost appears at the last step. A rejected field cannot be corrected. The pay button fires twice on a slow connection. There is no summary of what is being bought at the moment of paying. The tax is wrong or missing. Every one of these passes a happy-path test on your own laptop, because on your laptop you never reload, the network never stalls, and you type a valid postcode from memory.
What actually breaks?
| Symptom | Where it happens | What it costs | The fix in one line |
|---|---|---|---|
| Cart is empty after a reload | Any state kept only in React memory | The whole session | Persist to localStorage, rehydrate in an effect |
| Cart wiped by the app itself | Rehydration racing the first save | Same, but harder to reproduce | Do not save until the load has finished |
| Shipping revealed at the last step | Cost computed after the address form | The buyer feels ambushed and leaves | Show it before the buyer is bound, from the cart |
| Rejected field cannot be fixed | Error text with no link to the input | Total block, no recovery | aria-invalid, aria-describedby, focus the field |
| Double charge or duplicate order | Slow network, impatient second click | A refund and a complaint | Disable while pending, idempotency key server side |
| No summary at the payment step | Went straight from cart to card form | Hesitation at the worst moment | Line items, shipping and total, visible on the page |
| Wrong or missing VAT | Prices stored excluding tax, consumer-facing | A legal problem, not a UX one | Consumer prices displayed inclusive |
Why does the cart empty itself on reload?
Because the cart lives in a useState and a reload throws the whole tree away. The buyer opens a product in a new tab, comes back, and the cart is gone. They rarely rebuild it.
The fix is small, and the trap is inside the fix:
// cart-storage.ts
const KEY = 'cart:v2' // version it: an old shape will crash JSON consumers
export type CartLine = {
variantId: string
quantity: number
priceCents: number
}
function isLine(value: unknown): value is CartLine {
if (typeof value !== 'object' || value === null) return false
const l = value as Partial<CartLine>
return (
typeof l.variantId === 'string' &&
Number.isInteger(l.quantity) &&
(l.quantity ?? 0) > 0 &&
Number.isInteger(l.priceCents)
)
}
export function loadCart(): CartLine[] {
if (typeof window === 'undefined') return [] // server render: always empty
try {
const raw = window.localStorage.getItem(KEY)
if (!raw) return []
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter(isLine) : []
} catch {
return [] // private mode, quota exceeded, corrupted JSON
}
}
export function saveCart(lines: CartLine[]): void {
try {
window.localStorage.setItem(KEY, JSON.stringify(lines))
} catch {
// storage disabled: the cart still works for this session
}
}
Now the trap. The obvious hook wipes the cart on every page load:
export function useCart() {
const [lines, setLines] = useState<CartLine[]>([])
const [loaded, setLoaded] = useState(false)
useEffect(() => {
setLines(loadCart())
setLoaded(true)
}, [])
useEffect(() => {
if (!loaded) return // without this line, the empty initial state
saveCart(lines) // overwrites the stored cart on first render
}, [lines, loaded])
return { lines, setLines, loaded }
}
Remove the if (!loaded) return and the save effect runs once with the initial [], before the load effect has put anything back. The cart is destroyed by the code that was meant to preserve it. It reproduces every single time and is invisible if you only ever test by clicking add-to-cart and going straight to checkout.
Two more details worth taking now. Never read storage during render — the server output and the first client render must match, or React throws a hydration error and the page flashes. And never trust the stored price: revalidate every priceCents against the server before charging, because localStorage is a text field the buyer can edit.

Why does the pay button charge twice?
Because the request took four seconds on a phone on a train, nothing on screen changed, and the buyer clicked again. This is not a rare edge case. It is the normal behaviour of a human facing an unresponsive button.
Two guards, and you want both. Client side, refuse to fire while a request is in flight:
export function PayButton({ totalLabel }: { totalLabel: string }) {
const [pending, setPending] = useState(false)
async function pay() {
if (pending) return
setPending(true)
try {
const res = await fetch('/api/checkout', { method: 'POST' })
if (!res.ok) throw new Error('checkout failed')
const { url } = (await res.json()) as { url: string }
window.location.href = url
// deliberately not clearing `pending`: the redirect is in flight
} catch {
setPending(false) // only on failure, so the buyer can retry
}
}
return (
<button onClick={pay} disabled={pending} aria-busy={pending}>
{pending ? 'Taking you to payment…' : `Pay ${totalLabel}`}
</button>
)
}
The comment on the missing setPending(false) matters. Resetting it in a finally re-enables the button during the redirect, which is precisely the window where a second click lands.
Server side, make a repeated request return the same session rather than a new one:
// app/api/checkout/route.ts
const inFlight = new Map<string, Promise<{ url: string }>>()
export async function POST(req: Request) {
const { cartToken } = (await req.json()) as { cartToken: string }
const key = `checkout:${cartToken}`
const existing = inFlight.get(key)
if (existing) return Response.json(await existing) // same session, not a second one
const job = createCheckoutSession(cartToken, { idempotencyKey: key })
inFlight.set(key, job)
job.finally(() => setTimeout(() => inFlight.delete(key), 60_000))
return Response.json(await job)
}
Be honest about what that map is: a per-process guard that covers the double click and nothing else. It does not survive a restart and it does not span instances. The real protection is the idempotencyKey you pass to the payment provider, which every major one supports and which is the only mechanism that holds across processes.
Where should the shipping cost appear?
Before the buyer is committed, not after the address form.
The mechanical version: compute a shipping figure from the cart alone — a flat rate, a rate by weight band, a rate by destination once the buyer has chosen a country — and show it in the cart, next to the subtotal. If you genuinely cannot know it yet, say what determines it: Shipping from $4.90, calculated at the next step.
In the EU this is not a preference. The Consumer Rights Directive requires the total price, inclusive of taxes and delivery charges, before the consumer is bound by the contract; where the charge cannot reasonably be calculated in advance, you must say that it will be payable. Nothing about that is unusual, and the interface consequence is simply that the total is not allowed to be a surprise.
If you are showing a free-shipping threshold instead, the number has to be the amount still missing in currency rather than a percentage, and it has to be set against your margin — the arithmetic is here.
Why can the buyer not fix the field you rejected?
Three causes, all of them common in generated forms.
The error is not attached to the input. A red sentence at the top of the page, or a toast that disappears, and no programmatic link. The buyer cannot tell which field, and a screen reader announces nothing at all.
<input
id="postcode"
name="postal-code"
autoComplete="postal-code"
inputMode="text"
aria-invalid={!!error}
aria-describedby={error ? 'postcode-error' : undefined}
/>
{error && <p id="postcode-error">{error}</p>}
The validation is wrong. A postcode regex written for one country rejects a valid Eircode, a Canadian K1A 0B1, or a UK SW1A 1AA with its space. A phone field rejects the leading +. There is no recovery from a rule that is simply false, and the buyer has no way to know it is the rule that is broken rather than their own address.
The field should not be required. A company name, a second address line, a birth date. Every mandatory field is a place the checkout can end. Ask for what you need to ship and to charge, and stop.
One more, small and worth doing: after a failed submit, move focus to the first invalid field. Otherwise the buyer is looking at a red border somewhere below the fold with the keyboard caret still at the top.
What about the summary and the VAT?
Two things, both at the moment of paying.
The summary. Line items, quantities, shipping, tax and total, on the same screen as the card fields. A page that goes straight from a cart to a payment form asks the buyer to remember what they are about to spend. They will scroll back to check, and some of them will not come back. The layout for this is a solved problem.
The tax. For consumer sales in the EU, prices shown must include VAT — that is what the price indication rules require, and it is not a formatting preference. This is the single most common thing to get wrong when the product data comes from an ecommerce API that stores net prices, because the number renders perfectly and is simply the wrong number.
While you are there: the order button has to say what it does. EU rules require the consumer to explicitly acknowledge that the order carries an obligation to pay, with the button labelled unambiguously — Order with obligation to pay, Buy now, Pay $58.00. A button reading Continue, Confirm or Complete at that step is not compliant, and the sanction is that the buyer is not bound by the contract at all. This is a description of the rules, not legal advice; check your own jurisdiction and your product category.
The ten-minute test before you take money
Run this on a real phone, on cellular data, not on your laptop.
- Add two items, reload the page. Cart intact.
- Add an item, open the product in a new tab, come back. Cart intact.
- Turn on network throttling. Click pay twice, fast. One order.
- Enter a valid postcode from a country you did not think about. Accepted.
- Submit the form with one field empty. Focus lands on that field, and the message sits next to it.
- Look at the payment step. Line items, shipping, tax and total, all visible without scrolling back.
- Check one product price against a calculator. Tax included, if you sell to consumers in the EU.
- Read the button. It says what it costs, or that it obliges you to pay.
Everything on that list is twenty minutes of work and it is the difference between a store that runs and a store that takes money. If you are still choosing between owning this code and renting a platform that already handles it, that trade-off is worked through here.
uxgen is an MCP that hands a coding agent commerce components as HTML the merchant keeps, from $19 a month. It has no paying customers yet and the site says so on every page. The cart mechanics are MIT and readable without installing anything: github.com/kinerette/uxgen-commerce-kit.
FAQ
Why does my cart empty when the page reloads?
Because the cart lives in component state and a reload discards it. Persist the lines to localStorage and rehydrate them in an effect. The subtle failure is in the fix: if the save effect runs before the load effect has finished, the empty initial state overwrites the stored cart, so guard the save with a loaded flag. Never read storage during render either, or the server and client markup disagree and React throws a hydration error.
How do I stop a checkout button from charging twice?
Two guards. On the client, ignore clicks while a request is in flight and keep the button disabled through the redirect rather than re-enabling it in a finally. On the server, send an idempotency key derived from the cart to the payment provider so a repeated request returns the existing session instead of creating a second one. An in-memory map is a useful extra guard for the double click but does not survive a restart or span instances.
When do I have to show shipping costs?
Before the buyer is bound by the contract, which in practice means in the cart rather than after the address form. The EU Consumer Rights Directive requires the total price including taxes and delivery charges up front; if the charge genuinely cannot be calculated in advance, you must say that it will be payable. Showing a from-price with the condition attached satisfies both the rule and the buyer.
What should the pay button say?
What it costs, or that it obliges the buyer to pay. EU rules require the consumer to explicitly acknowledge the payment obligation, with the button labelled unambiguously — Pay $58.00, Buy now, or Order with obligation to pay. Vague labels such as Continue, Confirm or Complete do not meet that requirement, and the consequence is that the consumer is not bound by the contract.