GuidesFrameworksNode backend (BFF)

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/sdk
.env
FLIGHTDECK_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

src/store.ts
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 {}.

src/respond.ts
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.

src/cache.ts
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.

src/server.ts
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.js

Build 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.
  • FlightdeckError maps 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.