Overview
A Next.js 16 storefront and admin console for a custom clothing commerce platform. Customers browse a catalog, pick an exact variant, and check out in three steps; store staff operate a role-gated console for catalog, inventory, orders, coupons, and audit. The browser talks to one origin — the storefront rewrites /api/v1/* to the Express API — so session cookies stay first-party and no token ever touches JavaScript.
The clever part
Reads and writes travel through the same Axios instance. A single next.config.ts rewrite makes browser traffic same-origin, so SameSite=Lax cookies work identically locally (3001 → 3000) and in production (Vercel → Render) with no code change.
Where the code lives
Feature-sliced, not layered by file type. Each business area owns its API, hooks, and UI together:
src/
app/
(storefront)/ products/[productId] cart checkout orders account/*
(auth)/ login register (landing)/ verify-email forgot-password
admin/ products categories inventory orders users reviews coupons analytics audit admins
api/auth-cookie-reset/ dev-ui/
features/<domain>/ api.ts hooks.ts components/ schemas.ts
lib/api/ axios-instance.ts client.ts csrf.ts queryKeys.ts session-events.ts
components/ guards.tsx layout/ ui/ seo/
types/ Paginated<T> catalog.ts orders.ts ...
- Routes are file-system routes grouped by intent:
(storefront)customer-facing,(auth)public,adminconsole. Layouts enforce isolation —app/admin/layout.tsxwraps every admin page inAdminGate. - Features are self-contained —
features/catalog/api.tsknows its params,features/cart/hooks.tsknows its mutations,features/admin/product-components/*knows its dialogs. No cross-feature imports except throughlib/apiandtypes. - Types are the contract — hand-written from
D:\code\ecommerce'sdocs/api/**, shared asPaginated<T>,ProductDetail,Cart,Order, etc. One file per domain.
Request flow — one path for everything
Every server-state operation travels the same narrow path:
- Axios instance (
lib/api/axios-instance.ts) is created once:baseURL = NEXT_PUBLIC_API_BASE_URL ?? "/api/v1",withCredentials: true,Content-Type: application/json. - Envelope unwrapping (
lib/api/client.ts:unwrap) preserves pagination for any response carrying top-levelpagination. Listing hooks always type asPaginated<T>— typing as bareT[]compiles but throws at runtime (not iterable), a bug guarded against since76a6de0. - Pagination dialects — the API's meta (
page/limit/total/totalPages/hasNext/hasPrev) is normalized from either top-levelpaginationor nesteddata.pagination, tolerating both older and current envelope shapes. - Query keys (
lib/api/queryKeys.ts) are a centralized factory:qk.products(params),qk.cart,qk.admin.orders(params). Full params are part of the key so cache identity is exact.
Authentication and guards
Sessions are opaque cookies, never JWTs, never in JavaScript.
- Session context (
features/auth/session-context.tsx) holdsuser,isAdmin,isSuperAdmin, and their pending flags. It polls no role field — sessions expose no role — so admin visibility is a probe pattern:GET /admin/products(admin) andGET /admin/audit(super-admin) with200 vs 403distinguishing staff tiers. The backend always enforces real authorization; probes drive only UI visibility. - Guards (
components/guards.tsx) are four gates:AuthGate(requires session or redirects/login?from=),PublicOnlyGate(redirects authed users away from login/register),AdminGate(checksisAdmin, rendersForbiddenCardon failure),SuperAdminGate(checksisSuperAdminfor Analytics). - CSRF (
lib/api/csrf.ts+ interceptor inlib/api/client.ts) is double-submit, deduplicated.fetchCsrfToken()shares one inflight promise; request interceptor attachesx-csrf-tokenon every write; response interceptor on403with a CSRF error code/label retries exactly once after refreshing the token. A concurrent refresh never spawns N parallel fetches.
Admin console — an operations surface, not a demo
The /admin section mirrors every backend admin path with a dedicated editor, each behind AdminGate:
| Route | Scope |
|---|---|
/admin | KPI row, revenue trend (recharts), status pipeline links |
/admin/analytics, /admin/analytics/coupons, /admin/analytics/expenses | P&L ledger, coupon insights — SuperAdminGate |
/admin/products + /admin/products/[productId] | Product CRUD, variant tabs, image tabs with signed ImageKit uploads |
/admin/categories | CRUD + product-assign dialog, is_active visibility |
/admin/inventory | Per-variant ledger, manual reserve/release, reorder checks |
/admin/orders, /admin/orders/[orderId] | Queue with legal allowedTransitions rendering (order-status-actions.tsx) |
/admin/reviews, /admin/users, /admin/coupons, /admin/audit, /admin/admins | Moderation, customer management, coupon lifecycle, append-only audit, admin accounts |
Order status actions encode the legal transition matrix client-side and render only legal next steps — transitions still carry server-side stock/payment effects, so the UI cannot over-promise.
Media and errors
- ImageKit —
next.config.tsallowsik.imagekit.io/*.imagekit.io;lib/api/imagekit.tsobtains short-lived HMAC params fromGET /admin/products/uploads/imagekit-auth(or/uploads/imagekit-authfor reviews). The browser uploads directly; the client then registers the returned HTTPS URL — validated server-side for host, folder allowlist, and extension. The private key never leaves server config. - Money —
Decimal(10,2)serialized as fixed strings; rendered via a<Money>helper, neverparseFloat. - Errors —
normalizeApiErrormaps{ success: false, message, errors }and legacy{ error: { code, message } }into a typedApiError { status, code, message }. Queryretryinapp/providers.tsxis disabled for400/401/403/404/409/422and retries at most twice otherwise. Session expiry emitssession-events.tsand clears the QueryClient.
Honest gaps
- No client-side integration tests yet —
vitestis configured for unit tests only; theaxios-mock-adapterharness exists but no suite drives it end-to-end beyondclient.test.tsenvelope cases. - Admin probes are UI-only convenience — 200 vs 403 drives visibility; authorization is authoritative on the API.
- Image remotePatterns are allowlist-only — adding a new CDN requires a
next.config.tsedit.