← Journal
methodology·18 May 2026·6 min read

Shippingshop.propeleri.rs:ausedhockeyequipmentmarketplaceonNuxt4andCloudflarePagesedgeISR

Three weeks, one hockey club brief, an edge-rendered Supabase marketplace that caches forever and invalidates on every write.

Today we shipped shop.propeleri.rs — a used hockey equipment marketplace for Serbia, built for HC Propeleri Novi Sad on Nuxt 4 and Cloudflare Pages with edge ISR on top of Supabase. Three weeks of part-time work alongside the main club site. This post is the engineering write-up: what the product does, how we cached it, and why the pattern reuses across any niche read-heavy marketplace.

The problem is small but real. Hockey is a niche sport in the Balkans, played here almost entirely by adult amateurs — rec-league players, late starters, returners. This is a senior-only market by design, no youth pipeline. A full kit costs €800+, sticks break, beginners graduate from rental to owned gear, players retire or move and need to clear a basement, and until now there was nowhere in the region to buy or sell used. Sellers were posting in Viber groups and on Facebook Marketplace, where listings die in a week and nobody can filter by stick flex or skate size. The club wanted a single place that local adult players would actually open.

What got built: a catalogue with hierarchical categories (sticks, skates, helmets, gloves, pads, goalie, services), filters by city, condition, price and per-category attributes (flex, curve, shaft length and so on), full-screen photo galleries with Product schema for SEO, and a "Reveal contacts" gate behind login so we both protect sellers from scrapers and get honest per-listing view metrics. On top of that: single-round offers on negotiable listings, favourites, saved searches that fire an in-app notification and a web push on new matches, price-drop history, bundle ads (sell a full kit as one listing with per-item attributes), block-a-seller, and report-a-listing with an admin moderation queue. Three languages: Serbian as the default at the root, Russian and English under /ru and /en. PWA-installable, View Transitions API for soft route swaps, web push via VAPID.

The interesting engineering decision was caching. The first version was a straight SSR-on-edge Nuxt app. Listings rendered fine, but every direct hit fired a fresh render against Supabase. For a low-traffic marketplace that is wasteful, and worse, it caps how many regions you can usefully run from. So we needed a cache. The question was which.

We rejected two obvious answers. Static generation (nuxt generate) was out — every new listing would mean a rebuild, which is a dead-end for a marketplace. Stale-while-revalidate with a short TTL was tempting but wrong: it triggers a background re-render every TTL expiry whether or not the data moved. On a low-traffic site that means most renders are wasted work. We wanted "cache survives until the underlying data actually changes, never longer."

What we ended up with is Nitro ISR with no TTL plus explicit invalidation on every mutation. routeRules in nuxt.config.ts marks /listings/:slug, /u/:slug, /categories and /services as isr: true. /help and /terms are prerender: true. Anything authenticated — /my/**, /favorites, /notifications, /sell/** — stays full SSR. The route renders once, sits in the Cloudflare edge cache indefinitely, and is dropped only when the listing itself changes.

The invalidation side lives in apps/shop/server/utils/cache.ts and exports three helpers: invalidateListing(slug, sellerSlug?), invalidateSeller(slug), invalidateCatalog(). They are thin wrappers around useStorage('cache').removeItem(...). Every server mutation that affects a rendered HTML page calls the right helper at the end of its success branch — publish, status change, price drop, photo add/delete/reorder, bundle item add/delete, profile patch. The one mutation that goes directly from the browser to Supabase (the main SellForm.submitEdit, which writes through RLS) hits a tiny owner-authed POST /api/listings/:id/invalidate-cache endpoint after success. The mental model is one sentence: the cache survives until the data moves, and every code path that moves the data is responsible for telling the cache.

The pattern has been quiet and boring in the best way. A single edge region serving thousands of catalog hits per day touches Supabase exactly once per mutation, not once per visitor. No TTL means no "wait a minute and refresh" UX for sellers — their edits are live the instant the response returns. And because invalidation is explicit, the failure mode is loud: if you forget to call a helper on a new mutation path, the bug is obvious on the next test and easy to fix. We would take this trade over a global TTL on any marketplace where listings are read-heavy and writes are rare.

The second non-trivial thing was making sure user-aware UI never leaks into the cached HTML. A listing detail page renders different controls depending on who is looking at it: the owner sees Edit and Price Drop, a logged-in non-owner sees Reveal Contacts and Make Offer, an anonymous visitor sees a Login CTA. If any of that ended up in the SSR pass, the first viewer to render the page would freeze their identity into the cache for everyone after. The fix is to wrap all four blocks in <ClientOnly>. The SSR pass is now identical for every viewer; the per-user UI re-hydrates on the client. Obvious in hindsight, easy to miss while you are moving fast.

A short aside on the data model. Around fifteen tables: listings, listing_photos, listing_attributes, listing_items (bundle children), listing_offers (single-round, optional message field, no thread), listing_favorites, listing_reports, categories (hierarchical), category_attribute_schemas (per-category fields), saved_searches, contact_views, user_blocks, services, service_categories, push_subscriptions. Categories carry a JSON attribute_schema, so adding "Toe pattern: square/round" to Sticks is one database row, not a code change. Sessions are host-scoped — we share the Supabase project with the main propeleri.rs club site but not the cookie, because an earlier attempt at a parent-domain cookie leaked between the two apps and broke hydration on the club site.

What we cut for launch: seller ratings and a payments flow. Neither is blocking the marketplace from being useful — once a buyer reveals contacts, the conversation moves to phone, Viber or Telegram, which is what local buyers actually prefer — but both are on the roadmap once we see how the first month behaves. What is next: tuning the saved-search digest cadence based on real signal, a lightweight seller verification badge, and standing the same architecture up for the first non-hockey community that has asked for it (motocross, then used skis). The marketplace pattern — read-heavy, write-rare, edge ISR with explicit invalidation — turns out to be reusable across niche sport communities, which is the actual product we accidentally built.

Ready when you are

Havesomethingtobuild?

Tell us what you're working on. We read every message and reply within one business day — with a real opinion and a rough number.

Fill a brief