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/sdkSet the environment variables
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.
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.
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.
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.
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.
Related
- SDK reference — every namespace and method.
- Integrate checkout — the full checkout flow.
- Build your own frontend — the BFF pattern in any framework.