sellIntegration
shopping_cart

Blueprint: an online store

Wire Pages, Workers, D1, Durable Objects, R2, KV and Turnstile into one checkout that actually holds up.

7Cloudflare products
4Core tables
3Checkout steps
1Platform
insights

What are we building?

We will build a small online store entirely on Cloudflare. Customers browse products, drop items into a cart, and check out with a card — and every moving part runs on Cloudflare's edge, with one external service: the payment provider.

Think of this page as a wiring diagram. Each Cloudflare product does one job: Pages serves the storefront, a Worker is the API brain, D1 is the SQL database, Durable Objects keep each cart and the stock count consistent, R2 holds product images, KV caches the catalog, and Turnstile plus the WAF guard the checkout.

storefront

Think of it like…

A physical shop: Pages is the shop window, the Worker is the cashier, D1 is the ledger in the back office, the Durable Object is the single basket in your hands (no one else can touch it), R2 is the stockroom of photos, and the payment provider is the bank's card terminal.

schemaSystem architecture

product images

checkout challenge

catalog cache

products and orders

cart and inventory

charge card

Shopper browser

WAF and DDoS shield

Pages storefront

Worker API

R2 image bucket

Turnstile

Workers KV

D1 database

Cart Durable Objects

External payment

account_tree

Each product's role

Seven Cloudflare products plus one external payment service make up the whole store. Here is exactly what each one is responsible for.

web

Pages — storefront

Hosts the static/React shop UI on the global CDN. The browser talks to it first.

bolt

Workers — API

The brain: routes /api requests, runs business logic, and talks to every storage layer.

database

D1 — SQL database

Stores products, customers, orders and order_items as relational tables.

lock_clock

Durable Objects — cart & stock

One instance per cart; serialises stock changes so two buyers can't oversell the last item.

image

R2 — product images

Object storage for photos, served with zero egress fees. The image_key column points here.

bolt_outline

KV — catalog cache

Caches the product list at the edge so browsing doesn't hit D1 on every page view.

verified_user

Turnstile — human check

A friendly CAPTCHA on the checkout form; the Worker verifies the token before charging.

shield

WAF — firewall

Blocks malicious traffic and bots before they ever reach Pages or the Worker.

credit_card

Payment provider — external

The only off-Cloudflare service. The Worker calls its API server-to-server to charge the card.

hub

One platform, many bindings

Because every product lives on Cloudflare, the Worker reaches them through bindings (env.DB, env.CART, env.IMAGES, env.CATALOG) instead of network calls with URLs and credentials. Less config, fewer secrets, lower latency.

schema

The data model

Four tables cover a basic store. A customer places many orders; each order contains many order_items; each order_item points at one product. The order_items table is the join that records how many of each product were bought, at what price.

schemaEntity relationships

places

contains

listed in

CUSTOMERS

int

id

PK

text

email

text

name

ORDERS

int

id

PK

int

customer_id

FK

text

status

int

total_cents

ORDER_ITEMS

int

id

PK

int

order_id

FK

int

product_id

FK

int

qty

PRODUCTS

int

id

PK

text

title

int

price_cents

int

stock

Create the tables

sqlschema.sql
-- schema.sql : the four core tables of the store
CREATE TABLE customers (
  id    INTEGER PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  name  TEXT
);

CREATE TABLE products (
  id          INTEGER PRIMARY KEY,
  title       TEXT NOT NULL,
  price_cents INTEGER NOT NULL,
  stock       INTEGER NOT NULL DEFAULT 0,
  image_key   TEXT                      -- object key inside the R2 bucket
);

CREATE TABLE orders (
  id          INTEGER PRIMARY KEY,
  customer_id INTEGER REFERENCES customers(id),
  email       TEXT,
  status      TEXT NOT NULL DEFAULT 'pending',  -- pending | paid | failed
  total_cents INTEGER NOT NULL,
  created_at  TEXT DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE order_items (
  id               INTEGER PRIMARY KEY,
  order_id         INTEGER NOT NULL REFERENCES orders(id),
  product_id       INTEGER NOT NULL REFERENCES products(id),
  qty              INTEGER NOT NULL,
  unit_price_cents INTEGER NOT NULL
);

CREATE INDEX idx_items_order ON order_items(order_id);
payments

Store money as integers

Notice price_cents and total_cents — we store money as whole cents (an integer), never as a floating-point dollar amount. Floats round badly and lose pennies; integers never do.

swap_vert

The checkout flow

Browsing is easy (read from KV/D1, show images from R2). The interesting part is checkout, where five things must happen in the right order — and roll back cleanly if the card is declined.

schemaCheckout sequence
PaymentD1"Cart DO"Turnstile"Worker API"BrowserPaymentD1"Cart DO"Turnstile"Worker API"BrowserPOST checkout with tokenverify tokenhuman okreserve stockdecrement stockrows updatedreserved and totalINSERT order pendingorder idcreate chargepaidUPDATE order paidorder confirmed
  • Verify the Turnstile token so bots can't hammer checkout.
  • Reserve stock inside the Cart Durable Object (atomic, no overselling).
  • Write a 'pending' order to D1 and get its id.
  • Call the external payment provider to charge the card.
  • On success mark the order 'paid'; on failure mark it 'failed' and release the stock.
warning

Reserve before you charge

Always reserve stock before calling the payment provider. If you charge first and then discover the item is sold out, you have taken money for goods you can't ship — and now owe a refund.

construction

Build it: bindings & code

Here is the real wiring: the wrangler bindings that connect the Worker to every product, the secrets, the storefront front-end, the checkout Worker, and the Cart Durable Object.

  1. Declare all bindings

    In wrangler.jsonc, bind D1, KV, R2 and the Cart Durable Object so the Worker reaches them as env.DB, env.CATALOG, env.IMAGES and env.CART.

    jsonc
    {
      "name": "store-api",
      "main": "src/index.js",
      "compatibility_date": "2025-01-01",
    
      "d1_databases": [
        { "binding": "DB", "database_name": "store-db", "database_id": "<your-d1-id>" }
      ],
      "kv_namespaces": [
        { "binding": "CATALOG", "id": "<your-kv-id>" }
      ],
      "r2_buckets": [
        { "binding": "IMAGES", "bucket_name": "store-images" }
      ],
      "durable_objects": {
        "bindings": [
          { "name": "CART", "class_name": "Cart" }
        ]
      },
      "migrations": [
        { "tag": "v1", "new_sqlite_classes": ["Cart"] }
      ]
    }
  2. Store the secrets

    Turnstile and the payment key never go in code — push them as encrypted secrets.

    bash
    # Turnstile secret key (verifies the checkout challenge)
    npx wrangler secret put TURNSTILE_SECRET
    
    # Your payment provider API key (Stripe, etc.)
    npx wrangler secret put PAYMENT_KEY
  3. Apply the schema

    Create the four tables in the remote D1 database from schema.sql.

    bash
    npx wrangler d1 execute store-db --remote --file=./schema.sql
  4. Upload images to R2

    Put each photo in the bucket, then save its key in the product row.

    bash
    # Upload a product image to the R2 bucket, then store its key in D1
    npx wrangler r2 object put store-images/tshirt-blue.jpg --file ./tshirt-blue.jpg
    
    npx wrangler d1 execute store-db --remote \
      --command "UPDATE products SET image_key = 'tshirt-blue.jpg' WHERE id = 1;"
  5. Deploy

    Ship the Worker; the storefront on Pages deploys from its own git push.

    bash
    npx wrangler deploy

Front-end (Pages storefront)

jspublic/store.js
// public/store.js -- runs in the browser on the Pages storefront
const API = "https://store-api.example.workers.dev";

// A stable id for this browser's cart (used to find its Durable Object)
const cartId = localStorage.getItem("cartId") || crypto.randomUUID();
localStorage.setItem("cartId", cartId);

// 1) Browse products (images load straight from R2)
async function loadProducts() {
  const res = await fetch(`${API}/api/products`);
  const products = await res.json();
  for (const p of products) {
    // <img src="https://images.example.com/<image_key>"> served by R2
    renderCard(p);
  }
}

// 2) Add to cart
async function addToCart(productId) {
  await fetch(`${API}/api/cart/add`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ cartId, productId, qty: 1 }),
  });
}

// 3) Checkout -- token comes from the Turnstile widget on the page
async function checkout(email) {
  const token = window.turnstile.getResponse();   // proves you are human
  const res = await fetch(`${API}/api/checkout`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ cartId, token, email }),
  });
  const { orderId, status } = await res.json();
  alert(`Order ${orderId}: ${status}`);
}

Checkout Worker (the API)

jssrc/index.js
// src/index.js -- the store Worker API (router)
export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    // Browse catalog: serve from the KV cache, fall back to D1
    if (url.pathname === "/api/products") {
      let catalog = await env.CATALOG.get("catalog", "json");
      if (!catalog) {
        const { results } = await env.DB
          .prepare("SELECT id, title, price_cents, stock, image_key FROM products")
          .all();
        catalog = results;
        await env.CATALOG.put("catalog", JSON.stringify(results), { expirationTtl: 60 });
      }
      return Response.json(catalog);
    }

    // Add to cart: route to THIS shopper's Cart Durable Object
    if (url.pathname === "/api/cart/add" && request.method === "POST") {
      const { cartId, productId, qty } = await request.json();
      const stub = env.CART.get(env.CART.idFromName(cartId));
      return stub.fetch("https://do/add", {
        method: "POST",
        body: JSON.stringify({ productId, qty }),
      });
    }

    // Checkout
    if (url.pathname === "/api/checkout" && request.method === "POST") {
      const { cartId, token, email } = await request.json();

      // 1) Turnstile: prove the buyer is a human, not a bot
      if (!(await verifyTurnstile(token, env.TURNSTILE_SECRET)))
        return new Response("Failed challenge", { status: 403 });

      // 2) Reserve stock atomically inside the Cart Durable Object
      const cart = env.CART.get(env.CART.idFromName(cartId));
      const reserved = await (await cart.fetch("https://do/reserve", { method: "POST" })).json();
      if (!reserved.ok) return new Response("Out of stock", { status: 409 });

      // 3) Create a pending order in D1
      const order = await env.DB
        .prepare("INSERT INTO orders (email, status, total_cents) VALUES (?, 'pending', ?) RETURNING id")
        .bind(email, reserved.total_cents)
        .first();

      // 4) Charge the external payment provider
      const charge = await chargePayment(env.PAYMENT_KEY, order.id, reserved.total_cents);

      // 5) Confirm, or roll the stock back on failure
      const status = charge.paid ? "paid" : "failed";
      await env.DB.prepare("UPDATE orders SET status = ? WHERE id = ?")
        .bind(status, order.id).run();
      if (!charge.paid) await cart.fetch("https://do/release", { method: "POST" });

      return Response.json({ orderId: order.id, status });
    }

    return new Response("Not found", { status: 404 });
  },
};

async function verifyTurnstile(token, secret) {
  const r = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ secret, response: token }),
  });
  return (await r.json()).success === true;
}

async function chargePayment(apiKey, orderId, amountCents) {
  // Swap this for your provider's SDK / REST call
  const r = await fetch("https://api.payments.example/v1/charges", {
    method: "POST",
    headers: { authorization: "Bearer " + apiKey, "content-type": "application/json" },
    body: JSON.stringify({ amount: amountCents, currency: "twd", reference: orderId }),
  });
  return r.json();
}

Cart / inventory Durable Object

jssrc/cart.js
// src/cart.js -- one Durable Object instance per shopper cart.
// A single DO is single-threaded, so stock checks never race each other.
export class Cart {
  constructor(state, env) {
    this.state = state;   // durable, consistent storage for THIS cart
    this.env = env;
  }

  async fetch(request) {
    const url = new URL(request.url);
    let items = (await this.state.storage.get("items")) || [];

    if (url.pathname === "/add") {
      const { productId, qty } = await request.json();
      items = [...items, { productId, qty }];           // immutable update
      await this.state.storage.put("items", items);
      return Response.json({ items });
    }

    if (url.pathname === "/reserve") {
      // Decrement stock for every line, all-or-nothing.
      // 'WHERE stock >= ?' makes each decrement safe under load.
      let total = 0;
      for (const it of items) {
        const row = await this.env.DB
          .prepare("UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ? RETURNING price_cents")
          .bind(it.qty, it.productId, it.qty)
          .first();
        if (!row) return Response.json({ ok: false });  // sold out -> abort
        total += row.price_cents * it.qty;
      }
      return Response.json({ ok: true, total_cents: total });
    }

    if (url.pathname === "/release") {
      // Payment failed: give the reserved stock back.
      for (const it of items) {
        await this.env.DB
          .prepare("UPDATE products SET stock = stock + ? WHERE id = ?")
          .bind(it.qty, it.productId).run();
      }
      await this.state.storage.delete("items");
      return Response.json({ ok: true });
    }

    return new Response("DO route not found", { status: 404 });
  }
}
school

Why it works: the hard parts

shopping_basket

Cart consistency

Each cart maps to exactly one Durable Object via idFromName(cartId). All adds/reserves for that cart run on one instance, in order, so reads and writes never interleave or lose updates.

lock

DO for inventory

A Durable Object is single-threaded. Funnelling stock decrements through it means two shoppers grabbing the last item are handled one after another — no race, no overselling.

shield_lock

Protecting checkout

WAF blocks bad traffic at the edge; Turnstile proves a human is checking out; the Worker verifies the token server-side before it ever calls the payment provider.

cached

Cache then fall back

Browsing reads the catalog from KV first and only queries D1 on a cache miss, then refills KV. Fast pages, far fewer D1 reads.

link

Bindings, not URLs

env.DB / env.CART / env.IMAGES are bindings injected at deploy time. No connection strings, no API keys between your own services.

undo

Roll back on failure

If the card is declined, the order is marked 'failed' and the DO releases the reserved stock, so the shelf count stays honest.

tips_and_updates

Pitfalls & tips

verified

Always verify payment server-side

Never trust the browser to tell you an order was paid. Confirm payment by calling the provider's API (or a signed webhook) from the Worker, then update the order status from that result.

  • Keep the KV catalog TTL short (e.g. 60s) or purge the key whenever a product changes, so prices never go stale.
  • Make the payment call idempotent — pass the order id as the reference so a retried request never double-charges.
  • Store only image keys in D1; build the public R2 URL in the front-end. This keeps rows small and URLs swappable.
  • Use a webhook from the payment provider as a backstop in case the shopper closes the tab before the Worker sees 'paid'.
  • Add a periodic job (Cron Trigger) to release stock from carts that started checkout but never completed.
rocket_launch

Start small, grow into it

You don't need all seven products on day one. Begin with Pages + Worker + D1, add R2 when you have images, KV when browsing gets heavy, Durable Objects when stock races appear, and Turnstile/WAF before you take real money.