Node / Express BFF
Every storefront on this platform needs a server that holds the storefront token and talks to the commerce API — the BFF (backend-for-frontend). The other guides embed that server in a framework (Next.js route handlers, Remix actions, Astro endpoints). This guide builds it as a standalone Node service you can put behind any client: a React SPA, a native app, a static site, or another backend.
It covers the three things a production BFF actually needs: a narrow proxy
surface, caching for catalog reads, and error mapping from
FlightdeckError to honest HTTP responses.
The shape
any client -> your Node BFF -> @dscodotco/sdk -> commerce API
(holds the storefront
token, caches reads)The storefront token — an opaque secret (no fixed prefix), provisioned by the platform operator; platform-wide today — is server-side only and the API sends no CORS headers, so the client can never call the API directly. The BFF is the only component that ever sees the token.
Before you start
- Node 18+ (for global
fetch, which the SDK uses). - A tenant ref, a storefront token (an opaque secret, no fixed prefix,
provisioned by the platform operator; platform-wide today), and the API origin
https://api.ruo.pro.
Install
mkdir store-bff && cd store-bff
npm init -y
npm install express @dscodotco/sdkFLIGHTDECK_API_URL="https://api.ruo.pro"
FLIGHTDECK_TENANT="ruo-demo"
FLIGHTDECK_STOREFRONT_TOKEN="your-storefront-token"Load the token from the process environment or a secret manager — never a committed file. A missing token should fail startup loudly, not default to an empty string.
Construct the client and fail fast on missing config
import { createStorefrontClient } from "@dscodotco/sdk";
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing required env var: ${name}`); // fail startup, named
return value;
}
export const store = createStorefrontClient({
apiUrl: required("FLIGHTDECK_API_URL"),
tenant: required("FLIGHTDECK_TENANT"),
storefrontToken: required("FLIGHTDECK_STOREFRONT_TOKEN"),
});Map FlightdeckError to honest HTTP responses
The single most important BFF rule: a failed read is never an empty result.
A 404 from the API is real absence; anything else is “we could not find out”
and must surface as a 5xx, not as [] or {}.
import type { Response } from "express";
import { FlightdeckError } from "@dscodotco/sdk";
export async function respond<T>(res: Response, work: Promise<T>) {
try {
res.json(await work);
} catch (err) {
if (err instanceof FlightdeckError) {
// Forward the API's status and its { error: { code, message } } envelope.
res.status(err.status).json({ error: err.body });
return;
}
// Transport / unknown — 502, never a fabricated success.
res.status(502).json({ error: { code: "upstream_unavailable" } });
}
}Do not write catch { return res.json({ products: [] }) }. That tells the
client the store is empty when the store might simply be unreachable. Absent
and unknown are different answers — keep them different.
Cache catalog reads with a short TTL
Catalog and site reads are safe to cache briefly; checkout is never cached. A tiny in-memory TTL cache cuts API load without serving stale prices for long. Only cache successful reads — never cache a thrown error.
type Entry<T> = { value: T; expires: number };
const table = new Map<string, Entry<unknown>>();
export async function cached<T>(key: string, ttlMs: number, load: () => Promise<T>): Promise<T> {
const hit = table.get(key) as Entry<T> | undefined;
if (hit && hit.expires > Date.now()) return hit.value;
const value = await load(); // a throw propagates — nothing is cached
table.set(key, { value, expires: Date.now() + ttlMs });
return value;
}Keep the TTL short (30–60s) so merchant catalog edits appear quickly, and put
the cache behind the SDK call, not in front of the error mapper — a
FlightdeckError must still reach respond. For multi-instance deployments,
swap the Map for Redis with the same “cache success only” rule.
Expose a narrow proxy surface
Whitelist exactly the operations the client needs. Never forward arbitrary paths or let the client choose the tenant — the tenant is pinned in the client.
import express from "express";
import { store } from "./store.js";
import { respond } from "./respond.js";
import { cached } from "./cache.js";
const app = express();
app.use(express.json());
// Reads — cached briefly.
app.get("/api/site", (_req, res) => respond(res, cached("site", 60_000, () => store.site.get())));
app.get("/api/products", (_req, res) =>
respond(res, cached("products", 30_000, () => store.products.list())),
);
app.get("/api/products/:slug", (req, res) =>
respond(res, cached(`product:${req.params.slug}`, 30_000, () => store.products.get(req.params.slug))),
);
app.get("/api/collections/:slug", (req, res) =>
respond(res, cached(`col:${req.params.slug}`, 30_000, () => store.collections.get(req.params.slug))),
);
app.get("/api/search", (req, res) =>
respond(res, store.search({ q: String(req.query.q ?? "") })), // search is not cached
);
// Writes — never cached; deterministic idempotency key.
app.post("/api/checkout", (req, res) => {
const { cartId, attempt, items, card, email } = req.body;
respond(
res,
store.checkout.submit(
{ customer_ref: `guest:${email}`, items, card, email, ip: req.ip },
{ idempotencyKey: `cart-${cartId}:attempt-${attempt}` },
),
);
});
app.listen(Number(process.env.PORT ?? 3000));The checkout body carries raw card fields (ccnumber, ccexp, cvv), so any
server that touches it is in PCI-DSS scope. Never log the request body, and
keep card fields flowing client -> your HTTPS BFF -> the API only. See
Integrate checkout.
Run and deploy
node --env-file=.env src/server.jsBuild a container and run it on any host (Render, Fly, ECS). Inject the three env vars as runtime secrets — never bake them into the image.
Checklist
- The token is loaded from a secret and its absence fails startup by name.
- The proxy whitelists specific operations; the client never picks the tenant or a raw path.
- Catalog reads are cached with a short TTL; only successful reads are cached.
- Checkout is never cached and uses a deterministic idempotency key per attempt.
-
FlightdeckErrormaps to its real status; a transport failure is a 5xx, never an empty result. - The card-bearing checkout body is never logged.
What this guide does not cover
- Catalog and collection writes — merchant-authored in the console; the SDK
is read plus checkout only. For operator/merchant
fdk_surfaces, use the merchant client. - Token provisioning — the storefront token (an opaque secret, no fixed prefix, platform-wide today) is issued to you by the platform operator.
Related
- SDK reference — every namespace and method.
- Integrate checkout — the full checkout flow.
- Build your own frontend — the BFF pattern in any framework.