DEV Community

Cover image for Shipping a coffee storefront before the data model was ready
Alex DevOps engineer
Alex DevOps engineer

Posted on

Shipping a coffee storefront before the data model was ready

We just shipped two big surfaces for Brewly Store: a Catalog / Shop page and a full product page (PDP). On the surface it's an ordinary e-commerce release — a filterable grid, a product detail view with size and grind pickers, a taste profile. The interesting part isn't the features. It's that we shipped all of it before the backend GET /products endpoint existed, and the whole release hinged on one discipline: never let a placeholder pretend to be a working feature.

Here's what that looks like in the actual code.

The project

Brewly Store homepage —

Brewly Store is a coffee e-commerce platform running on Cloudflare's edge:

  • Frontend — a Nuxt 4 storefront (Vue 3, Pinia, TailwindCSS) on Cloudflare Pages.
  • Backend — a Cloudflare Workers API in Hono with @hono/zod-openapi, backed by Cloudflare D1 (SQLite at the edge).
  • i18n — every surface is bilingual (EN / UA); the Ukrainian route is just /uk in front of the path, e.g. brewly.online/uk/catalog/coffee.

At release time the products themselves still live in a static frontend module (app/data/products.ts) — the GET /products API isn't there yet. That single fact shaped every decision below.

What shipped

Catalog / Shop/catalog/coffee: a "Brewly Shop" header with a live product count, sorting (Featured / Price ↑ / Price ↓), a filter sidebar (Roast level, Type, Grind size, Taste notes, Price), a 3-column grid with black "Add to Cart" buttons, and "Load more" pagination with numbered pages.

Product page (PDP) — e.g. /product/ethiopia-yirgacheffe: breadcrumbs, a taste rating drawn as coffee beans (Bitterness / Sweetness / Acidity), size (250 g / 1000 g) and grind selection, a quantity stepper, a reactive price, accordions (Shipping / How to brew / Origin details), a Taste Profile block, and a "You may like" section.

All of it renders, responds, and looks finished. Behind it, the data was uneven — and that's where the real work was.

The real story: shipping ahead of the data

We had four real products with confirmed data and dedicated pages — brazil-santos, colombia-supremo, ethiopia-yirgacheffe, kenya-nyeri — and a design that called for attributes and inventory the backend didn't carry yet. The temptation there is to fake it: fill the grid with fake cards, wire up filters that don't filter, and hope nobody clicks. That collapses the first time a real user clicks a control that does nothing.

We took the opposite approach — ship what's real as real, and mark everything else as visibly provisional. Three decisions carried the release.

1. Real filters vs. presentational placeholders

Brewly Shop catalog — Roast level filter, 3-column grid, black Add to Cart buttons

Two filters are backed by actual product data and genuinely narrow the grid: Roast level and Price. The other three — Type, Grind size, Taste notes — are in the design, but there's no data behind them yet. So they render as UI, but the filtering logic simply doesn't consult them. That decision is one comment and one return in the catalog page:

const filteredProducts = computed(() =>
  allProducts.value.filter(product => {
    const roastMatch =
      filters.value.roast.length === 0 || filters.value.roast.includes(product.roastKey)
    const priceMatch =
      filters.value.price.length === 0 ||
      filters.value.price.some(band => matchesPriceBand(product.basePrice, band))

    // Type / Grind / Taste are visual-only until the backend exposes that data.
    return roastMatch && priceMatch
  })
)
Enter fullscreen mode Exit fullscreen mode

Active filters show up as removable chips with a "Clear all" reset — but only Roast and Price ever change the result set, so those are the only two that can actually strand you in an empty grid. When the backend grows a type facet, it joins the return line; nothing else moves.

2. Demo products vs. real products

To make an early catalog feel populated without lying about inventory, the grid mixes the four real products with clearly-separated demo fillers. Their whole reason for existing is written on the type in data/products.ts:

/**
 * Placeholder catalog fillers used only to populate the 3-column shop grid and
 * pagination until the backend `GET /products` endpoint exists. These reuse the
 * four real product photos and carry hard-coded (non-i18n) proper names, since
 * they are demo SKUs, not real inventory. They are excluded from PDP linking.
 */
export type DemoProductEntry = { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

The two lists are merged in one composable, with the demo entries flagged so nothing downstream can mistake one for the other:

export function useCatalogCards() {
  const realCards = useProductCards()

  return computed<ProductCardItem[]>(() => {
    const demoCards = demoCatalog.map(entry => ({ /* ...entry */ isDemo: true }))
    return [...realCards.value, ...demoCards]   // real first, fillers after
  })
}
Enter fullscreen mode Exit fullscreen mode

And the flag is load-bearing in exactly one place that matters — the card's link. A demo card renders identically but has no navigation overlay, so it can never route to a product page that doesn't exist:

<NuxtLink
  v-if="!product.soldOut && !product.isDemo"
  :to="productTo"
  class="absolute inset-0 z-[1]"
/>
Enter fullscreen mode Exit fullscreen mode

This keeps the mechanics — sorting, pagination, layout — testable end-to-end today, while the real catalog fills in behind them. (The same guard keeps the prerender crawler from ever hitting a non-existent PDP.)

3. Optimistic "Add to Cart" before there's a cart

There's no cart or checkout backend yet — our Pinia app store literally has no cart state in it. Rather than hide the primary call-to-action until it's fully wired, "Add to Cart" ships as an optimistic, visual-only interaction: it flips a label to "Added to cart" for 1.6 seconds and does nothing else.

function handleAddToCart() {
  if (p.value.soldOut) return

  addedFlash.value = true
  clearTimeout(addedTimeout)
  addedTimeout = setTimeout(() => {
    addedFlash.value = false
  }, 1600)
}
Enter fullscreen mode Exit fullscreen mode

It's honest — it does exactly what it appears to do, no more — and it lets us validate the button's placement, states, and copy now. Swapping this stub for a real cart store action later touches one function, not the whole PDP.

Meanwhile the parts that are wired are fully real. The price genuinely reacts to the size selection, because the weight options carry a multiplier:

export const productWeightOptions = [
  { label: '250g', multiplier: 1 },
  { label: '1000g', multiplier: 3.4 }
]

const unitPrice = computed(() => {
  const option =
    productWeightOptions.find(o => o.label === selectedWeight.value) ?? productWeightOptions[0]!
  return Math.round(p.value.basePrice * option.multiplier)
})
Enter fullscreen mode Exit fullscreen mode

Same for the quantity stepper and the total. If a control moves a number, that number is real; if a control is decorative, it's decorative all the way down. No half-wired middle ground.

A small thing I'm happy with: the bean rating

The taste scores (Bitterness / Sweetness / Acidity) aren't stars — they're little coffee beans, drawn as inline SVG with a gradient fill so they read as beans, not dots, at their 22px size. Each instance mints unique gradient ids so several ratings on one page don't collide on the same <defs>. It's a tiny component, but it's the kind of detail that makes a storefront feel considered rather than assembled.

What we deliberately didn't ship

Being explicit about the edges is part of the same discipline:

  • Mobile catalog — the sidebar-to-drawer filter layout is desktop-only for now; the mobile pass is next.
  • Real cart / checkout — the visual feedback above is the whole feature today.

Naming these keeps the release legible: the working parts are trustworthy, and the gaps are known rather than accidental.

Takeaways

  • Ship what's real as real, and make everything else visibly provisional. A dead control that looks live is worse than an obvious placeholder — it teaches users the UI lies.
  • Put "is this backed by data?" in one place. When live-vs-placeholder is a single comment on a return (or a single isDemo flag), turning a feature on is a one-liner instead of an archaeology project.
  • Demo data is fine — as scaffolding, not as fake inventory. Fillers that exercise sorting and pagination are useful; fillers that route to product pages that don't exist are a trap. One flag keeps the two honest.
  • Optimistic UI is honest when it admits what it is. "Added to cart" with no cart is fine if it does exactly that and nothing more.

A full catalog and PDP, shipped on top of a data model that's still filling in — not by faking the missing half, but by drawing a clear line between what's real and what's coming.


About the author

I'm Alex — a DevOps engineer building Brewly Store, a coffee e-commerce platform that runs entirely on Cloudflare's edge (Workers, D1, Pages). I write about edge architecture, shipping discipline, and the debugging stories that come with production.

How do you handle shipping UI ahead of the backend? Tell me in the comments.

Top comments (0)