Build a custom frontend with a secure BFF
You do not have to use the hosted renderer or the starter. Any framework works — Next.js, Remix, SvelteKit, Astro, a bare Node server — as long as you follow one rule: the storefront token is a server-side secret. This guide builds the backend-for-frontend (BFF) shape that keeps it that way.
The shape
shopper's browser -> YOUR server (BFF) -> @dscodotco/sdk -> Flightdeck API
(holds the storefront token)The browser talks only to your server. Your server holds the storefront token and makes every SDK call — reads and writes both. Because it is all same-origin (browser to your server), there is no CORS to configure, and the token never ships in a bundle.
The storefront token — an opaque secret (no fixed prefix), provisioned by the
platform operator; platform-wide today — authorizes placing orders. Keep it in
a server env var (FLIGHTDECK_STOREFRONT_TOKEN), read it only in server code,
and never prefix it NEXT_PUBLIC_. A browser fetch straight to the API is
blocked by design (no CORS headers) — that is the contract, not a bug to work
around.
Before you start
- A storefront token — an opaque secret (no fixed prefix), provisioned by the platform operator; platform-wide today.
- Your tenant ref and the API origin (
https://api.ruo.pro). @dscodotco/sdkinstalled.
Steps
Construct the client in server code only
createStorefrontClient pins one tenant per deployment — every call is scoped
to options.tenant, so you can never accidentally address another store.
// lib/store.ts — imported ONLY by server modules.
import { createStorefrontClient } from "@dscodotco/sdk";
export const store = createStorefrontClient({
apiUrl: process.env.FLIGHTDECK_API_URL!, // https://api.ruo.pro
tenant: process.env.FLIGHTDECK_TENANT!, // e.g. nova-peptide
storefrontToken: process.env.FLIGHTDECK_STOREFRONT_TOKEN!,
});If you ever import this module into a "use client" component or a browser
bundle, stop — the token would leak. Reads and writes both go through your
server.
Read the catalog server-side
Catalog reads run in a server component or a route handler. Products carry an
image_url you can render directly:
// A React Server Component, or an API route — server-side.
import { store } from "@/lib/store";
export default async function Page() {
const { products } = await store.products.list({ limit: 24 });
return (
<Grid>
{products.map((p) => (
<Card key={p.slug} title={p.name} image={p.image_url} />
))}
</Grid>
);
}Money is integer cents everywhere (4400 is $44.00). Paging is limit/offset
(see Pagination and rate limits).
Reflect what the merchant authored
The merchant’s authored site — section layout, nav, theme tokens — is part of
the same public contract. Read it with store.site.get() so your frontend
mirrors the console instead of re-declaring nav and palette in code:
const { manifest, version } = await store.site.get();
const nav = manifest.template?.sections?.find((s) => s.type === "header-nav");
const links = nav?.props.links ?? []; // the authored menu
const theme = manifest.theme; // token tree -> your CSS custom propertiesAn unknown tenant or a non-live store throws a FlightdeckError (404) — never
an empty manifest. See Theming for the token conventions.
Place orders through your own handler
Card fields must never reach a third party from the browser. Post them to your own handler, which calls the SDK. Checkout requires a deterministic idempotency key so a retry replays instead of re-charging:
// app/api/checkout/route.ts (Next.js) — or your framework's equivalent.
import { store } from "@/lib/store";
export async function POST(request: Request) {
const body = await request.json();
const result = await store.checkout.submit(
{
customer_ref: body.customerRef,
items: body.items,
card: body.card, // received over HTTPS, forwarded server-side
},
{ idempotencyKey: `cart-${body.cartId}:attempt-${body.attempt}` },
);
return Response.json(result);
}The API has no server-side cart and no hosted checkout — checkout is one-shot. Keep the cart client-side and call the API only to price (catalog reads) and to place the order.
Distinguish absence from failure
A failed read is never an empty result. Catch FlightdeckError, branch on the
status, and never launder a thrown error into []:
import { FlightdeckError } from "@dscodotco/sdk";
try {
return await store.products.get(slug);
} catch (err) {
if (err instanceof FlightdeckError && err.status === 404) return notFound();
throw err; // transport / 5xx — do NOT swallow into an empty catalog
}See Handle errors for the full pattern.
Checklist
-
createStorefrontClientis imported only in server modules. - The storefront token is a server env var, never
NEXT_PUBLIC_. - Card fields post to your own handler, never to a third party from the browser.
- Reads distinguish
404(real absence) from thrown errors. - Checkout passes a stable, deterministic
idempotencyKeyper attempt.
Related
- Build your own frontend — the BFF rationale and the no-CORS contract in depth.
- SDK reference — every
@dscodotco/sdkmethod and type. - Handle errors —
FlightdeckError, retries, and 429 backoff.