Build a Storefront
Storefront architecture
Recommended architecture for a custom storefront: server-side API key handling, a Next.js proxy layer, caching, error mapping, and idempotency.
View as MarkdownThe Borkol storefront API is authenticated with a single tenant API key that can read every order and mutate every cart in the store. That one fact dictates the architecture: the key lives on a server you control, and the browser only ever talks to that server. This page describes the recommended shape, with Next.js as the worked example; the same layering applies to any server-rendered or backend-for-frontend stack.
The key stays server-side
Never ship the API key to the browser, a mobile binary, or any client-delivered bundle. Concretely:
- With
commerce:write, anyone holding the key can mutate any cart in the store and convert carts to orders. GET /v1/api/commerce/orders/{id}has no customer-ownership check; the key alone authorizes it. A leaked key exposes every order's email and address.- The rate limit (120 requests per minute) is per store, not per visitor. A key in the browser lets one visitor exhaust the whole store's budget.
In Next.js terms: the key lives in an environment variable without the NEXT_PUBLIC_ prefix, and is only read in route handlers, server components, and server actions. Grep your client bundles for the key prefix during CI if you want a hard guarantee.
The customer session token (X-Customer-Token) is also a bearer credential. Keep it in a server-side session or an httpOnly cookie your proxy reads; do not hand it to client-side JavaScript.
The proxy pattern
Give the browser a small, storefront-shaped API served by your own app, and let that layer call Borkol:
Browser ── /api/* (your app) ── https://api.borkol.com/v1/api/*
|
adds Authorization: Bearer {key}
adds X-Customer-Token from the visitor's session
enforces YOUR ownership rules
maps Borkol errors to UI-safe shapesA minimal Next.js route handler pair:
import { cookies } from "next/headers";
const BASE = process.env.BORKOL_API_URL; // https://api.borkol.com
const KEY = process.env.BORKOL_API_KEY;
async function borkol(path, init = {}) {
const jar = await cookies();
const customerToken = jar.get("customer_token")?.value;
return fetch(`${BASE}${path}`, {
...init,
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
...(customerToken ? { "X-Customer-Token": customerToken } : {}),
...init.headers,
},
cache: "no-store",
});
}
export async function POST() {
const res = await borkol("/v1/api/commerce/carts", { method: "POST" });
const body = await res.json();
const jar = await cookies();
jar.set("cart_id", body.data.id, { httpOnly: true, sameSite: "lax", path: "/" });
return Response.json(body, { status: res.status });
}
export async function GET() {
const jar = await cookies();
const cartId = jar.get("cart_id")?.value;
if (!cartId) return Response.json({ data: null }, { status: 200 });
const res = await borkol(`/v1/api/commerce/carts/${cartId}`);
return Response.json(await res.json(), { status: res.status });
}Rules for the proxy layer:
- The cart id is the visitor's handle. Store it in an
httpOnlycookie set by your proxy, never trusting a cart id sent by the client. Whoever knows a cart id can read and mutate that cart through your proxy, so treat it like a session id. - Enforce order ownership yourself. Before proxying
GET /v1/api/commerce/orders/{id}, check that the order id was produced by this visitor's own checkout (for example, store the order id in the session at checkout time). For logged-in customers, preferGET /v1/api/commerce/my/orders, which Borkol scopes to the token for you. - Do not build a generic passthrough. A route that forwards any path under
/v1/apiwith your key attached recreates the leaked-key problem one hop away. Expose only the operations your UI needs, with the narrowest inputs. - Attach
X-Customer-Tokenserver-side from your session on cart, checkout, quote-request, and customer endpoints. This gives logged-in visitors their group pricing everywhere without the client ever seeing the token.
Caching: catalog yes, carts never
The API splits cleanly into cacheable reads and per-visitor state.
Cache freely (shared, seconds to minutes):
GET /v1/api/commerce/categories: the tree changes rarely. Minutes of TTL is fine.GET /v1/api/commerce/productsandGET /v1/api/commerce/products/{id}for guest traffic: cache per URL (includingfilter[category]andpage). This is also your main lever against the 120 requests per minute limit; a busy catalog page served from cache costs zero budget.GET /v1/api/commerce/configurator/products/{id}/schema: cache per product for minutes; merchants edit configurators infrequently.
In Next.js, use fetch revalidation (next: { revalidate: 60 }) or ISR on catalog pages, and invalidate via revalidateTag from your webhook receiver when a stock.adjusted event arrives.
Never cache, or cache only per-visitor:
- Any request carrying
X-Customer-Token. Product responses are priced per customer context (group prices, ex-VAT display, hidden guest prices), so a shared cache would leak one customer's pricing to another. Either skip caching for logged-in traffic or key the cache by customer group. - Carts, checkout, shipping methods for a cart, quote endpoints,
my/orders: alwayscache: "no-store". Cart responses are the source of truth for totals and policy; a stale cart renders wrong prices. POST /v1/api/commerce/configurator/products/{id}/quote: it is cheap and selection-dependent; memoize at most per exact request body within a session.
Stock display: stock_status is embedded in cached product responses, so it goes stale with the cache. That is acceptable because checkout re-checks stock authoritatively and returns a structured 409 if something ran out; treat catalog stock badges as advisory.
Error handling and idempotency
Map the Borkol error envelopes once, in the proxy, into shapes your UI switches on. The complete catalog is on Errors and rate limits; the ones your storefront must handle by code rather than by message:
| Status | Discriminator | Storefront reaction |
|---|---|---|
| 401 | message: "Unauthenticated." | Your key is bad or revoked. Operational alert, not a user error. |
| 401 | message: "Invalid customer token." | Customer session expired. Clear the session, ask the visitor to log in again. |
| 403 | code: "tenant_suspended" | Store-wide outage state. Show a maintenance page. |
| 403 | error: "Module not enabled: ..." | Misconfiguration (key or store). Operational alert. |
| 403 | message: "This cart belongs to a customer." | Cart is bound to an account. Prompt login, then retry with the token. |
| 409 | code: "QUOTE_REQUIRED" | Route to the quote request flow. |
| 409 | message: "Cart contents changed." | Re-render the cart from the cart object in the error body; surface out_of_stock and discount_unavailable; let the buyer retry. |
| 409 | code: "QUOTE_NOT_OFFERED" / "OFFER_CHANGED" / "OUT_OF_STOCK" | Quote token page states: expired/withdrawn, changed (ask merchant to re-send), or shortage. |
| 422 | code present (login_required, guest_only, b2b_approval_required, b2c_only, min_order_value, cart_constraint) | Cart policy violations: each maps to one specific UI state. |
| 422 | errors object | Field validation. Render per-field messages; the API's messages are user-ready. |
| 429 | message: "Too Many Attempts." | Honor Retry-After. Back off; do not retry-storm. |
Idempotency and retries:
- Checkout is safe to retry. Re-POSTing checkout for an already-converted cart returns the existing order with 200. On a network timeout during checkout, retry the same request instead of showing an error; you cannot create a duplicate order.
- Quote accept is idempotent the same way: accepting an already-accepted quote returns the same
order_id. - Cart line adds are NOT idempotent. Retrying a successful
POST .../linesmerges quantities (2 becomes 4). Only auto-retry line mutations on network-level failures where no response arrived, and reconcile by re-fetching the cart and comparing to the expected state rather than blindly re-POSTing. - Reads are always safe to retry with exponential backoff on 429 and 5xx.
- Budget the rate limit: 120 requests per minute for the whole store means cache catalog reads, batch nothing into per-render fan-outs, and never poll order status faster than webhooks would tell you anyway.
Environment variables
Conventions that keep the key out of the client bundle and the environments separable:
# Server-only: no NEXT_PUBLIC_ prefix, ever, on these three.
BORKOL_API_URL=https://api.borkol.com
BORKOL_API_KEY=1|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
BORKOL_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Client-safe values carry the prefix and contain no secrets.
NEXT_PUBLIC_STORE_NAME="Four Seasons Garden"- One key per environment. Create separate keys named for their deployment (
storefront-prod,storefront-staging) so each can be revoked independently andlast_used_atin the dashboard tells you which is live. - Scope keys minimally per surface. A build-time catalog prerenderer needs only
commerce:read; give it its own read-only key rather than reusing the checkout server's write key. - The webhook secret is per endpoint and shown once at creation, like the API key. Store both in your secret manager, not in the repository.
- Key rotation is create-new, deploy, revoke-old. Keys have no expiry, so rotation discipline is yours.