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/EditableBackgroundImagecomponents writing to a key/valuecontenttable, withrevalidatePath('/', 'layout')after each mutation. - Solo build. No code review, no QA team, no managed observability beyond what Supabase and Stripe expose by default.
Stack
| Layer | Choice | Why not the obvious alternative |
|---|---|---|
| Frontend | Next.js 14.2 App Router | Route groups (public) / (admin) and server components |
| Backend | Co-located API routes + Pino logging | One runtime, no separate Express service |
| Database | Supabase Postgres, RLS, French GIN full-text | Migrated from MongoDB mid-project (d6bafce) — relational shape fit the data |
| Auth | Custom JWT (jose 5.9) + bcrypt 12 rounds | One admin, minimal surface — Supabase Auth would have been overkill at the time |
| Payments | Stripe Checkout, API 2025-12-15.clover | Hosted checkout = no PCI scope to carry |
| Storage | Supabase Storage (media bucket) | Migrated from local filesystem (4babfd6) — Docker rebuilds invalidated post-build files |
| Hosting | Scaleway Dedibox + Docker + nginx + Let's Encrypt | Fixed cost, full control, sovereignty pressure |
| Rate limit | In-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.sqlintroducing astripe_eventstable withevent_idas primary key. Every webhook now checks idempotency before processing. - All admin reads switched from
supabase(anon) togetSupabaseAdmin()(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).
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:
ordersadmin list —3b1fb5ashippingzones admin toggle —5b53ec4blogunpublished posts admin —266689contactsadmin 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:
app/api/webhooks/stripe/route.ts— idempotent webhook with explicit 5xx on failuresupabase/migrations/001_initial_schema.sql— typed ENUMs, French GIN full-text, RLS,updated_attriggerssupabase/migrations/003_stripe_events.sql— the idempotency patchapp/api/checkout/route.ts— server-side price validation, DBCHECKconstraints, anti client-side tamperingapp/api/upload/route.ts— magic-number validation, not mime-type sniffing
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.erroris not observability. The custom/api/logendpoint 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.