React SPA + BFF proxy
A single-page React app (Vite, Create React App, or any client-rendered build) ships entirely to the browser. That is exactly why it cannot hold the storefront token — and why every SPA on this platform needs a small server in front of it. This guide builds that server (the BFF) and the React app that talks to it.
Why the browser cannot call the API directly
The storefront token — an opaque secret (no fixed prefix), provisioned by the platform operator; platform-wide today — authorizes placing orders. If it shipped in your JS bundle, anyone could read it and charge cards against your store. So two things are true by design:
- The token is server-side only. It never appears in client code.
- The API sends no CORS headers (this is deliberate, not a
misconfiguration). A
fetchfrom a shopper’s browser straight tohttps://api.ruo.prois blocked by the browser — reads included.
The consequence: your SPA talks only to your BFF (same origin, so no CORS), and the BFF holds the token and talks to the API through the SDK.
shopper's browser (React SPA) -> your BFF -> @dscodotco/sdk -> commerce API
(no token) (holds the storefront token)Do not try to “work around” CORS with a public proxy or by embedding the token. The BFF is the contract. There is no supported browser-direct path.
Before you start
- Node 18+.
- 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.
Scaffold the SPA and the BFF
npm create vite@latest my-store -- --template react-ts
cd my-store
npm install
npm install @dscodotco/sdk express # express for the BFF; use any serverKeep the BFF in the same project so it can serve the built SPA and proxy the API under one origin.
Configure the server environment
FLIGHTDECK_API_URL="https://api.ruo.pro"
FLIGHTDECK_TENANT="ruo-demo"
FLIGHTDECK_STOREFRONT_TOKEN="your-storefront-token"With Vite, only variables prefixed VITE_ reach the client. Never name the
token VITE_.... The three variables above are read only by the BFF process.
Write the BFF proxy
The BFF exposes a narrow, whitelisted set of endpoints — a read passthrough for catalog and a checkout endpoint. It never forwards arbitrary paths.
import express from "express";
import { createStorefrontClient, FlightdeckError } from "@dscodotco/sdk";
const store = createStorefrontClient({
apiUrl: process.env.FLIGHTDECK_API_URL!,
tenant: process.env.FLIGHTDECK_TENANT!,
storefrontToken: process.env.FLIGHTDECK_STOREFRONT_TOKEN!,
});
const app = express();
app.use(express.json());
// Map a thrown FlightdeckError to an honest HTTP response.
function send<T>(res: express.Response, work: Promise<T>) {
work
.then((data) => res.json(data))
.catch((err) => {
if (err instanceof FlightdeckError) return res.status(err.status).json({ error: err.body });
// A transport / unknown error is a 502 — never a fake empty result.
return res.status(502).json({ error: { code: "upstream_unavailable" } });
});
}
app.get("/api/products", (_req, res) => send(res, store.products.list()));
app.get("/api/products/:slug", (_req, res) => send(res, store.products.get(_req.params.slug)));
app.get("/api/search", (req, res) => send(res, store.search({ q: String(req.query.q ?? "") })));
app.post("/api/checkout", (req, res) => {
const { cartId, attempt, items, card, email } = req.body;
send(
res,
store.checkout.submit(
{ customer_ref: `guest:${email}`, items, card, email, ip: req.ip },
{ idempotencyKey: `cart-${cartId}:attempt-${attempt}` },
),
);
});
app.use(express.static("dist")); // serve the built SPA under the same origin
app.listen(3000);Read catalog from the SPA
The React app calls its own BFF with a relative URL — same origin, so no CORS.
import { useEffect, useState } from "react";
type Product = { id: string; slug: string; name: string; image_url: string | null };
export function ProductList() {
const [products, setProducts] = useState<Product[] | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
fetch("/api/products")
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d) => setProducts(d.products))
.catch(() => setError(true)); // distinguish failure from "no products"
}, []);
if (error) return <p>Could not load the catalog. Try again.</p>;
if (!products) return <p>Loading…</p>;
return (
<ul>
{products.map((p) => (
<li key={p.id}>
{p.image_url ? <img src={p.image_url} alt={p.name} width={240} /> : null}
<a href={`/product/${p.slug}`}>{p.name}</a>
</li>
))}
</ul>
);
}Note the error state is distinct from the empty state. catch(() => setState([]))
would tell a shopper “this store has nothing” when the truth is “we could not
reach the store”. A failed read is never an empty result.
Post checkout through the BFF
The cart lives in the browser (localStorage) holding only { variantId, quantity }.
On submit, POST the items plus card to the BFF; card fields go browser -> your
HTTPS server -> the SDK, never to a third party from the browser.
export async function placeOrder(cartId: string, items: unknown[], card: unknown, email: string) {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ cartId, attempt: 1, items, card, email }),
});
if (!res.ok) throw new Error((await res.json()).error?.code ?? "checkout_failed");
return res.json(); // { order, card_charged_cents, ... }
}Deploy
Build the SPA (npm run build) and run the Express server on any Node host
(Render, Fly, a container). Set the three env vars in the host’s secret
store, never in the client build.
What this guide does not cover
- Catalog and collection writes — merchant-authored in the console; the SDK is read plus checkout only.
- Token provisioning — the storefront token (an opaque secret, no fixed prefix, platform-wide today) is issued to you by the platform operator; you do not mint it.
Related
- SDK reference — every namespace and method.
- Integrate checkout — the full checkout flow.
- Build your own frontend — the BFF pattern in depth.