Remix

Remix has a server layer built in: loaders run on the server for reads and actions run on the server for writes. That maps cleanly onto this platform — loaders and actions are your BFF, they hold the storefront token, and only their serialized return values reach the browser. This guide builds a catalog, a PDP, and a checkout entirely through that layer.

Why loaders and actions are the whole story

The storefront token — an opaque secret (no fixed prefix), provisioned by the platform operator; platform-wide today — authorizes placing orders and is server-side only. The API sends no CORS headers, so the browser cannot call it directly. In Remix you never need to: loaders and actions already run on your server. Import the SDK only in *.server.ts modules (or directly inside loaders/actions) and Remix guarantees that code is stripped from the client bundle.

browser  ->  Remix loader / action  ->  @dscodotco/sdk  ->  commerce API
                 (holds the storefront token)

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.

Create the app and install the SDK

npx create-remix@latest my-store
cd my-store
npm install @dscodotco/sdk

Set the environment variables

.env
FLIGHTDECK_API_URL="https://api.ruo.pro"
FLIGHTDECK_TENANT="ruo-demo"
FLIGHTDECK_STOREFRONT_TOKEN="your-storefront-token"

The token is read only in server code. Remix does not expose server env vars to the browser unless you deliberately return them from a loader — so do not.

Construct the client in a server-only module

The .server.ts suffix tells Remix’s compiler this module is server-only; an accidental client import is a build error.

app/store.server.ts
import { createStorefrontClient } from "@dscodotco/sdk";
 
export const store = createStorefrontClient({
  apiUrl: process.env.FLIGHTDECK_API_URL!,
  tenant: process.env.FLIGHTDECK_TENANT!,
  storefrontToken: process.env.FLIGHTDECK_STOREFRONT_TOKEN!,
});

List the catalog in a loader

The loader runs on the server; its return value is serialized to the route component. Render image_url straight from the response.

app/routes/_index.tsx
import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import { store } from "~/store.server";
 
export async function loader() {
  const { products } = await store.products.list();
  return json({ products });
}
 
export default function Index() {
  const { products } = useLoaderData<typeof loader>();
  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>
          <Link to={`/product/${p.slug}`}>
            {p.image_url ? <img src={p.image_url} alt={p.name} width={240} /> : null}
            {p.name}
          </Link>
        </li>
      ))}
    </ul>
  );
}

Build the PDP loader with an honest 404

Throw a Response for a real not-found so Remix renders the route’s error boundary; rethrow anything else — never collapse a transport error into empty data.

app/routes/product.$slug.tsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { FlightdeckError } from "@dscodotco/sdk";
import { store } from "~/store.server";
 
export async function loader({ params }: { params: { slug: string } }) {
  try {
    const { product } = await store.products.get(params.slug!);
    return json({ product });
  } catch (err) {
    if (err instanceof FlightdeckError && err.status === 404) {
      throw new Response("Not found", { status: 404 });
    }
    throw err; // 5xx / transport — surface it, do not fake an empty product
  }
}
 
export default function Product() {
  const { product } = useLoaderData<typeof loader>();
  const variant = product.variants[0];
  return (
    <main>
      <h1>{product.name}</h1>
      <p>${(variant.price_cents / 100).toFixed(2)}</p>
      <form method="post" action="/checkout">
        <input type="hidden" name="variantId" value={variant.id} />
        <input name="email" type="email" placeholder="Email" required />
        <input name="ccnumber" placeholder="Card number" required />
        <input name="ccexp" placeholder="MM/YY" required />
        <input name="cvv" placeholder="CVV" required />
        <button type="submit">Buy now</button>
      </form>
    </main>
  );
}

Place the order in an action

The action receives the posted form on the server and calls the SDK. The idempotency key is deterministic per attempt so a retry replays instead of re-charging.

app/routes/checkout.tsx
import { json, redirect } from "@remix-run/node";
import { FlightdeckError } from "@dscodotco/sdk";
import { store } from "~/store.server";
 
export async function action({ request }: { request: Request }) {
  const form = await request.formData();
  const email = String(form.get("email"));
  const variantId = String(form.get("variantId"));
  try {
    const result = await store.checkout.submit(
      {
        customer_ref: `guest:${email}`,
        items: [{ variant_id: variantId, quantity: 1 }],
        card: {
          ccnumber: String(form.get("ccnumber")),
          ccexp: String(form.get("ccexp")),
          cvv: String(form.get("cvv")),
        },
        email,
        ip: request.headers.get("x-forwarded-for") ?? undefined,
      },
      { idempotencyKey: `buynow-${variantId}-${email}:attempt-1` },
    );
    return redirect(`/order/${result.order.id}`);
  } catch (err) {
    if (err instanceof FlightdeckError) {
      return json({ error: err.body }, { status: err.status });
    }
    throw err;
  }
}

For a real cart, keep line items in client state or a cookie holding only { variantId, quantity }, and derive the idempotency key from a stable cart id plus attempt number. The platform re-prices every line from the live catalog at submit — never trust a client-held total. See Integrate checkout.

Deploy

Deploy the Node build to any Node host (Render, Fly, a container). Set the three env vars in the host’s secret store.

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.