projects

SERLEI

status
live
domain
serlei.ch
period
2025-10 → present (7 months at writing)
commits
140 (solo)
head
3b1fb5a · 2026-05-11

Production e-commerce for an independent Swiss upcycling fashion brand based in canton Vaud. Native CHF Stripe checkout, custom in-place CMS for a non-technical operator, self-hosted Docker stack on a Scaleway VPS. Around 73k insertions across 976 files. Fix-to-feature ratio 3.66 — a project that shipped fast and got stabilized in production, not in staging.

Context

The brand sells one-off upcycled pieces and made-to-measure. Direct-to-consumer, no marketplace, no third-party storefront. Constraints at start:

  • Swiss-first commerce. All amounts in CHF, shipping initially limited to ['CH'] and relaxed to EU in April 2026. Compliance with LPD (Swiss data protection law, not GDPR — different regime).
  • Sovereign hosting. Self-hosted on a Scaleway Dedibox in Paris, not Vercel. The client wanted infrastructure she could point at.
  • Non-technical operator. The creator runs the storefront herself. No Sanity, no Contentful — instead, in-place editing via EditableText / EditableImage / EditableBackgroundImage components writing to a key/value content table, with revalidatePath('/', 'layout') after each mutation.
  • Solo build. No code review, no QA team, no managed observability beyond what Supabase and Stripe expose by default.

Stack

LayerChoiceWhy not the obvious alternative
FrontendNext.js 14.2 App RouterRoute groups (public) / (admin) and server components
BackendCo-located API routes + Pino loggingOne runtime, no separate Express service
DatabaseSupabase Postgres, RLS, French GIN full-textMigrated from MongoDB mid-project (d6bafce) — relational shape fit the data
AuthCustom JWT (jose 5.9) + bcrypt 12 roundsOne admin, minimal surface — Supabase Auth would have been overkill at the time
PaymentsStripe Checkout, API 2025-12-15.cloverHosted checkout = no PCI scope to carry
StorageSupabase Storage (media bucket)Migrated from local filesystem (4babfd6) — Docker rebuilds invalidated post-build files
HostingScaleway Dedibox + Docker + nginx + Let's EncryptFixed cost, full control, sovereignty pressure
Rate limitIn-memory Map<key, {count, resetAt}>Single Docker instance — Redis was unjustified at this scale

Decisions worth revisiting are in the retrospective.

The webhook incident

2026-05-11. Three independent failures stacked together and broke the order pipeline silently for an unknown duration.

Symptom

The admin orders list returned empty. Customers paid successfully on Stripe's hosted checkout, the database recorded the orders, but neither the operator's admin nor Stripe's webhook reconciliation surfaced anything.

Root cause, in order of severity

1. Webhook URL pointing to www.serlei.ch instead of the apex. nginx serves a 301 redirect from www → apex. Stripe does not follow 3xx on webhook delivery. Result: every webhook event since launch had been silently dropped at the proxy layer. The misconfiguration lived in the Stripe dashboard, invisible to anything in the code.

2. Outer try/catch in handlePaymentSuccess swallowing errors. When the handler did fire — for the rare events that somehow landed on the right URL — database write failures inside the try block were caught at the top level and the function returned 200 OK to Stripe. Stripe interpreted that as successful processing and never retried.

3. RLS misuse on admin reads. Functions like getOrders / getOrderById / countOrders used the anonymous Supabase client. RLS only grants SELECT on orders to service_role. Result: queries returned zero rows with no error raised. The admin UI fell back on data.orders || [] and rendered "no orders yet".

The compound effect: Stripe processed payments, the database recorded the orders, and the operator could see none of them.

Fix

Single commit, 3b1fb5a:

  • New migration 003_stripe_events.sql introducing a stripe_events table with event_id as primary key. Every webhook now checks idempotency before processing.
  • All admin reads switched from supabase (anon) to getSupabaseAdmin() (service role).
  • Outer try/catch removed from the webhook handler. Failures now return 5xx and force Stripe to retry.
  • Admin UI distinguishes empty (no orders yet) from error (failed to load — retry button).
  • Webhook URL corrected in the Stripe dashboard (apex, no www).

Webhook handler post-fix.

Detection

Not via alerting — there was none. The operator noticed the gap manually while reconciling sales. Time-to-detect: unknown but almost certainly substantial. This is the single most important lesson from the project and the reason monitoring is the first item in the retrospective.

Pattern: the same root cause, four times

The "admin read forgot getSupabaseAdmin()" bug appeared in four independent places before the webhook fix codified the pattern across the codebase:

  • orders admin list — 3b1fb5a
  • shipping zones admin toggle — 5b53ec4
  • blog unpublished posts admin — 266689
  • contacts admin list — ad7ec0

Each time, RLS silently returned an empty result instead of an error. The fifth occurrence is not a question of if but when. A custom ESLint rule banning the anonymous client inside /app/api/admin/* would have prevented all four.

Worth reading

Files that, opened cold by a senior engineer, show how the system actually works:

Retrospective

Five things I would change if I rebuilt this system today:

  • External error monitoring from day one. The webhook incident stayed invisible because nothing was watching. console.error is not observability. The custom /api/log endpoint introduced post-incident is a band-aid — Sentry or equivalent should have been in the stack from the first deploy.
  • Lint the RLS pattern. A custom rule banning the anonymous client in /app/api/admin/* would have prevented four production incidents with the same root cause. Cost: an afternoon. Saved: probably more than an afternoon.
  • Output: standalone mode. The Next.js standalone flag was added and removed three times across the project. Standalone with nginx serving /images/ directly from the filesystem would have been the cleanest Docker setup. The reason for abandoning it does not survive in the commit history, which is itself a problem.
  • Custom auth vs. managed. JWT + bcrypt + cookies for a single admin is defensible but the maintenance surface (rotating JWT_SECRET, password reset, eventual MFA) keeps growing. Supabase Auth is already in the stack and would have collapsed the surface.
  • MongoDB → Supabase migration in production. Doable solo but risky — the cleaner pattern is a parallel-write phase before cutover, and I didn't have the team to support one. It became a hard switch that worked because the data volume was small. At higher scale, this is the kind of call I'd push to make with at least one other engineer in the room.

build

Workflow: spec → plan-first session → parallel subagents → automated review → manual call on the gray areas. Production decisions, architecture, debugging, and incident response are mine. Code generation is the agent's. The portfolio itself documents this workflow in /projects/portfolio.