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 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
/ukin 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
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
})
)
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 = { /* ... */ }
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
})
}
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]"
/>
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)
}
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)
})
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 singleisDemoflag), 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.
- 🌐 Live project: brewly.online
- 🎥 YouTube — building the Brewly Telegram bot on Cloudflare Workers: watch the walkthrough
- 💼 LinkedIn: alex-d
- 🐦 X: @mainoceanm
- ✉️ mainoceanm@gmail.com
How do you handle shipping UI ahead of the backend? Tell me in the comments.
Top comments (0)