The Next.js routes under app/api/ are thin proxies that forward to NUMU-api. They own auth, CSRF, idempotency, and subdomain → store_id resolution.Why proxy#
If the storefront talked directly to https://api.numueg.app/api/v1 from the browser:1.
Cookie scope wouldn't work. Customer auth cookies are scoped to the storefront domain, not the API.
2.
CORS would need to be wide-open for every storefront subdomain.
3.
CSRF would be hard. Double-submit cookies must be same-origin.
4.
Subdomain resolution would leak. The browser knows the subdomain via Host, but a cross-origin fetch loses it.
Proxying through https://<sub>.numueg.app/api/* solves all four — cookies are first-party, the proxy stamps x-numu-host, and the backend can trust it.Categories#
Cart writes — /api/cart/*#
| Path | Backend route | CSRF | Idempotency |
|---|
POST /api/cart/add | POST /storefront/cart/add | ✅ | optional |
POST /api/cart/remove | POST /storefront/cart/remove | ✅ | optional |
POST /api/cart/update | POST /storefront/cart/update | ✅ | optional |
POST /api/cart/discount | POST /storefront/cart/discount | ✅ | – |
DELETE /api/cart/discount | DELETE /storefront/cart/discount | ✅ | – |
Helper: lib/cart-proxy.ts → proxyCartMutation(req, path, method).Cart read — /api/cart#
GET direct passthrough to GET /storefront/cart. No CSRF (safe method). Cookie forwarded as-is.Customer — /api/customer/*#
Auth routes (store-scoped, need subdomain resolution):| Path | Backend route |
|---|
POST /api/customer/register | POST /storefront/store/{store_id}/auth/register |
POST /api/customer/login | POST /storefront/store/{store_id}/auth/login |
POST /api/customer/logout | POST /storefront/store/{store_id}/auth/logout |
POST /api/customer/recover | POST /storefront/store/{store_id}/auth/recover |
POST /api/customer/reset | POST /storefront/store/{store_id}/auth/reset |
POST /api/customer/verify-email | POST /storefront/store/{store_id}/auth/verify-email |
POST /api/customer/resend-verification | POST /storefront/store/{store_id}/auth/resend-verification |
/me routes (cookie-scoped, no subdomain resolution needed):| Path | Backend route |
|---|
GET /api/customer/me | GET /storefront/me/profile |
PATCH /api/customer/me | PATCH /storefront/me/profile |
POST /api/customer/me/password | POST /storefront/me/password |
GET /api/customer/me/addresses | GET /storefront/me/addresses |
POST /api/customer/me/addresses | POST /storefront/me/addresses |
PATCH /api/customer/me/addresses/{id} | PATCH /storefront/me/addresses/{id} |
DELETE /api/customer/me/addresses/{id} | DELETE /storefront/me/addresses/{id} |
GET /api/customer/orders | GET /storefront/me/orders |
GET /api/customer/orders/{id} | GET /storefront/me/orders/{id} |
POST /api/customer/orders/{id}/reorder | POST /storefront/me/orders/{id}/reorder |
GET /api/customer/saved-cards | GET /storefront/me/saved-cards |
Helper: lib/customer-proxy.ts → proxyCustomer(req, { backendPath, method, requireCsrf }). Handles cookie + CSRF + subdomain resolution + Set-Cookie passthrough (multi-cookie via getSetCookie()).Checkout — /api/checkout#
POST /api/checkout → POST /storefront/checkout
No CSRF on the proxy itself — the backend's idempotency-key cache prevents double-submission. The Idempotency-Key header is required and stamped by the storefront's ReviewStep client.Public reads — /api/storefront/*#
| Path | Backend route |
|---|
GET /api/storefront/apps | GET /storefront/store/{store_id}/apps |
GET /api/storefront/apps/{slug} | GET /storefront/store/{store_id}/apps/{slug} |
GET /api/storefront/currencies | GET /storefront/store/{store_id}/currencies |
GET /api/storefront/unlock | POST /storefront/store/{store_id}/password/unlock |
GET /api/storefront/pickup-locations | GET /storefront/store/{store_id}/pickup-locations |
GET /api/storefront/checkout-config | GET /storefront/store/{store_id}/checkout-config |
GET /api/storefront/store | GET /storefront/store-by-subdomain/{sub} |
All use proxyCustomer(req, { requireCsrf: false }) since they're idempotent reads.Gift cards — /api/gift-cards/{code}#
GET /api/gift-cards/{code} → GET /storefront/store/{store_id}/gift-cards/{code}
Phase 8.3 balance check. Public read, no auth.GET /api/image-transform?url=&w=&q=&f=
Proxies to the merchant-configured image-resizing service (Cloudflare Image Resizing by default, self-hosted as fallback). The SDK's <Image> builds srcset URLs targeting this proxy.Other public reads#
| Path | Purpose |
|---|
GET /api/products/{id}/related | Same-category siblings — drives useRelatedProducts |
GET /api/shipping/options | Resolve shipping rates for an address (POST shape) |
CSRF protocol#
The storefront sets a numu_csrf cookie on first response (a random opaque token). Mutating proxies require the same token echoed in the x-numu-csrf header.import { verifyCsrf } from "@/lib/csrf";
export async function POST(req: NextRequest) {
const err = verifyCsrf(req);
if (err) return NextResponse.json({ error: "csrf_invalid" }, { status: 403 });
// ... forward
}
Themes that call mutating proxies must:1.
Read the numu_csrf cookie via document.cookie
2.
Echo it as the x-numu-csrf request header
The SDK's useCart(), useCustomerActions(), etc. do this automatically.Idempotency-Key protocol#
Mutations that create resources (checkout, cart-add) accept an Idempotency-Key header. The backend caches the response keyed by (store, customer, key) for ~10 minutes. Re-sending the same key returns the cached response — double-clicks don't double-charge.The SDK + storefront UIs generate UUIDs per user-intended action. Themes that bypass the SDK should generate keys themselves.Set-Cookie passthrough#
Login + register return a customer_access_token cookie. The proxy forwards the upstream Set-Cookie header to the browser unchanged. The cookie is HttpOnly, Secure (in prod), SameSite=Lax, scoped to the storefront domain.// Inside proxyCustomer, simplified
const upstream = await fetch(...);
const response = new NextResponse(upstream.body, { status: upstream.status });
for (const c of upstream.headers.getSetCookie()) {
response.headers.append("Set-Cookie", c);
}
return response;
getSetCookie() is Node 18+ — multi-cookie responses (rare but possible) are preserved.Body forwarding#
Non-safe methods forward req.text() directly. This preserves any content-type quirks:const body = SAFE_METHODS.has(method) ? undefined : await req.text();
const headers: Record<string, string> = {};
if (body !== undefined) headers["Content-Type"] = "application/json";
// ...
We never req.json() + re-stringify — that would normalize away type fidelity (multipart, fields with null vs undefined, etc.).Adding a new proxy#
If you need to expose a new backend route to themes:1.
Add the route file under app/api/<your-path>/route.ts.
2.
Cart writes → proxyCartMutation
Customer auth or /me → proxyCustomer
Public read → proxyCustomer with requireCsrf: false
Direct passthrough → write your own with fetch + cookie + x-numu-host
3.
Test cookie passthrough (Set-Cookie in response).
4.
Update routing if it's user-facing. 5.
Update API surface so theme devs know the backend route exists. Modified at 2026-09-24 13:03:12