AZ
All writing
3 min read

A catalog with no cart (on purpose)

nextjsmongodbproductcommercewhatsapp
Part of the projectMogadgetA single-owner Lagos gadget catalog with no cart — browse, then hand off to a prefilled WhatsApp/Instagram chat, with fire-before-navigate click analytics.View project

Most catalog apps end at a cart. Mogadget deliberately doesn't have one. There is no checkout, no online payment, and no customer account anywhere in the product — the store owner sells through WhatsApp and Instagram, so the app's job ends the moment it hands a ready-to-send message to the buyer's chat app. The product doc states it outright: "no step in this flow touches a cart, checkout, or account system." This post is about what replaces the cart, and how to instrument it without ever getting in the sale's way.

The hand-off is the order flow

Browse → product page → tap "Chat on WhatsApp" or "DM on Instagram" → a deep link opens with a prefilled message that names the exact product and price → negotiation and payment happen entirely in-chat. The whole "checkout" is one pure function that builds a wa.me URL:

export function buildWhatsAppLink(p: { name: string; priceNaira: number; url?: string }): string {
  const base = `Hi, I'm interested in the ${p.name} (${formatNaira(p.priceNaira)}) listed on MoGadget`;
  const msg = p.url ? `${base}${p.url}` : base;
  return `https://wa.me/${WHATSAPP_NUMBER}?text=${encodeURIComponent(msg)}`;
}

That's the entire conversion mechanism. It's pure, so it's trivially unit-tested, and it lives in a domain/ layer with no database in sight.

Analytics that can't cost you a sale

The one thing you still want to know is which channel a buyer chose — but measuring it must never delay or block the tap. The client fires the beacon before navigation, using navigator.sendBeacon (which survives the page unload) with a keepalive fetch fallback, all wrapped so a failure is silent:

export function fireClickBeacon(slug: string, channel: TClickChannel): void {
  const path = `/api/products/${encodeURIComponent(slug)}/click`;
  const body = JSON.stringify({ channel });
  try {
    if (typeof navigator !== "undefined" && navigator.sendBeacon) {
      navigator.sendBeacon(path, new Blob([body], { type: "application/json" }));
      return;
    }
    void fetch(path, { method: "POST", body, keepalive: true });
  } catch { /* analytics are best-effort — never interrupt the sale */ }
}

The server side keeps the same posture: an atomic $inc on the product's channel counter moves first, and only then does it try to append to a time-series log — a failure there is logged and swallowed, never surfaced. The counter is the fast path; the event log is the nice-to-have.

No cron, no PII

That event log is a clickEvents collection with a MongoDB TTL index for 180-day auto-retention — the database expires old rows, so there's no cron job to run or forget — plus a { createdAt, channel } index behind a day-by-channel trend aggregation. It stores a slug, a channel, and a timestamp; no person, no device, nothing to leak.

The catalog's real rules live in a pure domain layer

Without a cart, the modelling weight shifts onto the catalog itself, and Mogadget puts those rules in domain/product.ts rather than scattering them through handlers. A NEW item must be restockable, carry no cosmetic grade, and track an integer quantity; a used unit must be a unique unit that carries a grade and has no quantity at all. assertProductInvariants rejects any mixture on every write, and a companion rule auto-hides a restockable listing the instant its quantity hits zero — but never auto-unhides it, because re-listing is a deliberate decision, not a side effect of a stock bump.

An honest footnote

The product doc argued for one admin and no permission framework. The shipped app has a full IAM stack behind a withPermission wrapper, plus passkeys and TOTP — and the doc's own "Historical note" flags that pivot rather than pretending the plan held. Dropping the cart didn't mean dropping the rigor; it just moved the rigor to where a no-cart store actually needs it — the invariants, the hand-off, and analytics that stay out of the way. 39 colocated Vitest specs guard exactly those parts.

Back to the projectMogadgetSee the full case study and related write-ups.View project