2026-09-24

A shop on a key-value store: index arrays, immutable Stripe prices and what you give up

I built a small storefront to live entirely inside Cloudflare’s free tier: React on the front, Hono on a Worker, KV as the only database and Stripe for payments. A KV store has no query language, no joins and no transactions, so the data model has to be designed backwards from the reads.

The key layout

Everything is either an index or an entity:

Key Value
products:all JSON array of product IDs
collections:all JSON array of collection IDs
media:all JSON array of media IDs
product:{id} the product document
collection:{id} the collection document
collection:products:{collectionId} array of product IDs in that collection
media:{id} media metadata
storefront:page:{slug} a page-builder document for a page such as home

Listing products is two steps: read products:all, then fetch each product:{id}. Listing a collection’s products reads the denormalised collection:products:{id} array, which trades write cost for a cheap read. The docs keep a “KV data model” page that must be updated whenever a key pattern changes in code, which is the closest thing a schemaless store gets to a migration.

What you give up

The reliability notes are blunt about the platform’s constraints:

My reading of the index design: products:all is a read-modify-write on a single value, so two admins creating a product at the same instant can lose one ID. For a single-admin shop that is fine. For anything busier you would move the index to a store with transactions, or make it derivable from entity keys. Knowing which invariant you have quietly stopped guaranteeing is the price of the free tier.

Stripe: prices are immutable

The store keeps Stripe in sync automatically, and the rules follow Stripe’s model, where a price never changes:

That “new price, never edit” rule mirrors a good general principle: a price attached to a past order is history.

Checkout and webhooks

Two paths exist: single-item checkout from a product page and cart checkout (a cart lives in the browser’s local storage until checkout). Both create a Stripe Checkout session, so card details never touch the Worker. The customer is redirected to a success or cancel URL. Webhooks (checkout.session.completed, payment_intent.succeeded) are optional, and when enabled the handler verifies Stripe’s signature.

Security by construction

The trust model fits on one line:

Browser (untrusted)  ->  HTTPS  ->  Worker (trusted)  ->  KV / Stripe

There is an explicit checklist for security changes, including “CSP still allows the required Stripe domains if checkout changes”, the kind of item that is easy to break and hard to notice.

Takeaways

← All posts