GuidesCustom frontend

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/sdk installed.

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 properties

An 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

  • createStorefrontClient is 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 idempotencyKey per attempt.