Ahmed Abdelaziz

Ecommerce Storefront & Admin ConsoleDesign Docs

Architecture Decisions

Six key choices and why they were made — proxy, sessions, state, and media.

Last updated August 2026

Overview

Six decisions that define the storefront. Each has context, choice, why, and trade-off — verified against next.config.ts, lib/api/*, features/auth/session-context.tsx, and components/guards.tsx.


ADR-001: The storefront proxies the API at the edge

Status: Accepted

Context: Browser → separate API origin means CORS configuration and third-party cookie risks, and every environment would need a distinct allowlist.

Decision: next.config.ts rewrites /api/v1/:path*${API_ORIGIN}/api/v1/:path* (API_ORIGIN trimmed and trailing-slash stripped). The browser talks to one origin; session cookies stay first-party. NEXT_PUBLIC_API_BASE_URL remains relative (/api/v1) so the Axios instance works identically locally (3001 → 3000) and on Vercel (→ Render).

Why: CORS disappears, cookies behave identically across environments, and the backend can move hosts without a client change.

Trade-off: Requests hop through the frontend host — negligible latency, one more moving part in deployment.

Alternatives: Direct browser-to-API with CORS_ORIGIN allowlist + SameSite=None; Secure cookies — correct but leaks CORS surface and requires per-environment tuning.


ADR-002: Opaque session cookies, probe-based role visibility

Status: Accepted

Context: Sessions expose no role payload; the UI still needs to decide which navigation to show.

Decision: SessionProvider probes staff tiers: GET /admin/products?page=1&limit=1 (admin vs non-admin, 200 vs 403) and, when admin, GET /admin/audit?page=1&limit=1 (super-admin vs admin). Results are cached 10 minutes (staleTime, no window-focus refetch) and drive isAdmin / isSuperAdmin. Guards (AdminGate, SuperAdminGate) render ForbiddenCard on failure; the API independently enforces authorization on every /admin/* path.

Why: UI visibility is responsive without a privileged token in JavaScript.

Trade-off: An extra probe per staff session — acceptable, and it avoids duplicating role issuance in the cookie.


ADR-003: One Axios instance, one CSRF flow

Status: Accepted

Context: Every cookie-authenticated write requires a double-submit CSRF token; N concurrent writes must not fan out to N token fetches.

Decision: A single axios instance (lib/api/axios-instance.ts, withCredentials: true) is shared; lib/api/csrf.ts shares one inflight fetchCsrfToken() promise; a request interceptor attaches x-csrf-token on writes, and a response interceptor on 403 with a CSRF error code retries the original request once after refreshing the token. Unknown 401s emit session-events.ts and clear the QueryClient.

Why: Callers never implement CSRF handling; concurrency is correct by construction.

Alternatives: Per-feature Axios instances (duplicated interceptors) or disabling CSRF for the storefront (weaker security posture).

Trade-off: A failed CSRF refresh that resolves to 401/403 clears cookies and broadcasts expiry — visible but correct, the user is forced to re-authenticate.


ADR-004: TanStack Query with a centralized key factory

Status: Accepted

Context: Listing params (page, limit, search, sort, status) must produce distinct, predictable cache entries — and must not be mirrored into local stores.

Decision: All server state lives in TanStack Query (app/providers.tsx, staleTime: 30s, disabled for 400/401/403/404/409/422, capped retries). Keys are built by a single factory (lib/api/queryKeys.ts): qk.products(params), qk.cart, qk.admin.orders(params), etc., with full params in the key.

Why: Adds a screen with zero cache ambiguity — any developer can invalidate or prefetch by key shape alone.

Alternatives: Zustand-mirrored server state (duplication, drift) or ad-hoc string keys (collisions, typo-prone invalidation).

Trade-off: Slightly larger key space, but it eliminates the "stale mirror" problem entirely.


ADR-005: Typed pagination envelope handling

Status: Accepted

Context: The API's paginated envelope carries both data and pagination at top level; naive consumers typing list calls as bare T[] compile yet explode at runtime.

Decision: lib/api/client.ts:unwrap normalizes both pagination locations, reconstitutes { ...body, pagination } or { ...body.data, pagination }, and callers type lists as Paginated<T> ({ data, pagination: { page, limit, total, hasNext, hasPrev } }). params.ts helpers (sortParam) derive sort strings from { field, desc } objects. Components read data and pagination together.

Why: A single unwrapping rule covers both pagination dialects; the Paginated<T> convention documents the pitfall that once caused a shipped bug (76a6de0).

Trade-off: One extra generic layer on every list type — cheap and self-documenting.


ADR-006: Direct-to-ImageKit signed uploads

Status: Accepted (mirrors backend ADR-0001)

Context: Product and review images are large; proxying bytes through the API wastes server resources while the CDN already serves them.

Decision: The client requests short-lived HMAC params from GET /uploads/imagekit-auth or GET /admin/products/uploads/imagekit-auth, uploads directly to ik.imagekit.io / *.imagekit.io, then registers the returned HTTPS URL via admin image endpoints. next.config.ts explicitly allows only ImageKit remotePatterns; persisted URLs are re-validated server-side for host, folder allowlist, and extension.

Why: The API never touches image bytes yet stays the security boundary.

Trade-off: A two-step flow (sign → upload → register) and an allowlist that must be maintained — but no bandwidth cost on the API.

Common thread

Security into the boundary (proxy + cookies + CSRF), correctness into the contract (typed envelopes, probe-based guards), and media onto the CDN — so application code stays boring on purpose.