Architecture
Tech stack, data flow diagrams, security model, and deployment shape of Hygge.
The system is intentionally small — a few stateless route handlers, a Postgres database, and three external APIs. There's no queue, no Redis, no microservices.
Stack#
| Layer | Choice | Why |
|---|---|---|
| Frontend | Next.js 16 (App Router, RSC) | Server components keep auth + DB out of the client bundle |
| Database | Supabase (Postgres + Auth + RLS) | Managed Postgres with built-in auth and Row-Level Security |
| Payments | Whop API + webhooks | We don't custody funds; per-merchant API keys |
| Storefront | Shopify Admin API + script tag | Standard OAuth scope set, no theme edits |
| Hosting | Vercel | Edge-friendly, our middleware runs there |
| Resend | Transactional only — receipts, payment failures, GDPR notifications | |
| Monitoring | Sentry + PostHog + Vercel Analytics | Error reporting, product events, perf |
The whole app runs in a single Next.js project. Two long-lived clients show up everywhere:
- Anon Supabase client — created from the user's session cookie, used in dashboard pages and OAuth flows. Honors RLS.
- Service-role Supabase client — used in webhooks and admin endpoints. Bypasses RLS by design.
Data flow#
Buyer payment path (the hot path)#
Shopify storefront payhygge.com Whop
───────────────── ───────────── ────
script_tag onload
│ intercepts checkout click
▼
POST /api/checkout/create-from-cart ──┐
◄── { checkoutId } │
│ │ Postgres INSERT checkouts
▼ │
POST /api/checkout/get-plan ───────────┼─── checkTransactionLimit ──── merchants table
│ 402 if limit reached or no sub │
◄── { planId } └─── POST /api/v2/plans ─────► Whop creates plan
│
▼
Redirect buyer to Hygge drawer
│
│ buyer pays via Whop
▼
POST /api/webhooks/whop ◄───── payment.succeeded
│ HMAC verify
│ INSERT transactions
│ POST /admin/api/orders ──► Shopify
▼
sendSaleNotificationEmail (Resend)
SaaS subscription path#
Same webhook endpoint (/api/webhooks/whop) handles two distinct event sources, disambiguated by data.product_id:
- If
product_idmatches one of our Hygge SaaS tier products → SaaS branch (membership + payment events updatemerchants.subscription_*) - Otherwise → buyer-payment branch (above)
We can do this safely because the secret used to verify a SaaS event (WHOP_WEBHOOK_SECRET_HYGGE) is different from the per-merchant buyer secret stored in merchants.whop_webhook_secret. The handler tries each in order.
Security model#
HMAC verification on every webhook. Both Shopify and Whop sign their payloads. We re-compute the HMAC against the raw body using a timing-safe comparison (crypto.timingSafeEqual). Failed verifications return 401 and are never persisted.
Per-merchant Whop secrets. Each merchant's whop_webhook_secret is stored encrypted at rest. Even if a single merchant's secret leaks, no other merchant's payments are forgeable.
Server-side price verification. Cart totals are re-fetched from Shopify's Admin API before we accept them. If a buyer manipulates the cart payload (DevTools, intercepting proxy), the server-computed total wins.
Row-Level Security on Supabase. Merchants can only read their own row, their own checkouts, their own transactions. Service-role access (used by webhooks + admin endpoints only) bypasses RLS — these endpoints are HMAC-verified or session-protected.
No PII in analytics. PostHog identifies merchants by Supabase user.id (UUID) only — no email, no IP, no card data. Sentry has sendDefaultPii: false and a beforeSend that strips cookie and authorization headers as a defense-in-depth.
GDPR + Shopify App Store. Three mandatory webhook handlers (customers/data_request, customers/redact, shop/redact) plus an audit log — see Webhooks for details.
Deployment#
Single Vercel project. The Next.js middleware runs on Vercel's edge runtime; everything else is Node.js serverless functions or RSC server-rendered pages.
- Cold starts: rare in practice (Vercel keeps frequently-hit functions warm), but the buyer payment path doesn't depend on the cold function — by the time the webhook fires, the buyer is already at the success page on Whop's side.
- Deploy cadence: every push to
mainauto-deploys. We use the standard Vercel preview deploys for PRs. - Rollback: instant, via Vercel UI. Database migrations are designed to be backward compatible (additive columns, no destructive renames in normal releases).
What this architecture is NOT#
- Multi-region. Single primary in Vercel/Supabase US East. Adequate for current scale; multi-region is a V2 problem when latency budgets get tighter.
- Queue-based. Webhooks process synchronously. Whop and Shopify both retry on non-2xx, which is our retry mechanism.
- Microservice'd. One Next.js project. We'd split out shop/redact and other long-running jobs into a worker if data volumes warrant it — not yet.