Build a storefront from scratch
This is the long one. By the end you will have a real, working storefront — a catalog grid, product detail pages, a cart, and a live one-shot checkout — built from an empty Next.js app against a store your operator provisioned. No fork, no template: you write every piece, so you understand every piece.
We use the Next.js App Router because its server-component and Route Handler
model matches the SDK’s one hard rule exactly: the x-storefront-token is
server-side only. Everything that touches it runs on your server; the
shopper’s browser only ever talks to your server.
Prefer not to build from scratch? Clone the ready-made Next.js Commerce fork instead. This tutorial is for understanding the SDK surface end to end, or for wiring commerce into an app you already have.
What you need
- A tenant ref — your store’s id (the public demo uses
ruo-demo). - A storefront token — the
x-storefront-tokencredential: an opaque secret (no fixed prefix), provisioned by the platform operator; platform-wide today. Server-side only; treat it like a password. There is no self-serve screen for it today. - The API origin —
https://api.ruo.pro. - Node 18+ and a package manager.
- A provisioned store with at least one active product. If yours is empty, seed it first with the scripted loop in Import an existing catalog.
What you will build
Scaffold the app
npx create-next-app@latest my-store --ts --app --eslint
cd my-store
npm install @dscodotco/sdkAccept the App Router when prompted. The only commerce dependency is
@dscodotco/sdk; its types ship prebuilt, so you never generate anything.
Set environment variables
Create .env.local. None of these are NEXT_PUBLIC_ — they must never reach
the browser bundle.
FLIGHTDECK_API_URL="https://api.ruo.pro"
FLIGHTDECK_TENANT="ruo-demo"
FLIGHTDECK_STOREFRONT_TOKEN="your-storefront-token"The storefront token authorizes placing orders. Read it only in server code
(server components, Route Handlers, server actions). If you ever prefix it
NEXT_PUBLIC_, you have shipped a checkout credential to every browser. Don’t.
Create the server-only SDK client
One module, imported only from server code. Marking it server-only turns an
accidental client import into a build error — cheap insurance for a
checkout-authorizing token.
npm install server-onlyimport "server-only";
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!,
});The client is tenant-pinned: tenant is injected into every request path,
so you never repeat it and can never accidentally address another store.
Render the catalog grid
The home page is a server component. It lists active products and renders each
with the image_url the catalog returns directly — no separate asset lookup.
import Link from "next/link";
import { store } from "@/lib/store";
export const dynamic = "force-dynamic"; // catalog reads hit the API at request time
export default async function HomePage() {
const { products } = await store.products.list(); // no query args today
return (
<main style={{ maxWidth: 960, margin: "0 auto", padding: 24 }}>
<h1>Catalog</h1>
<ul style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16, listStyle: "none", padding: 0 }}>
{products.map((p) => (
<li key={p.id}>
<Link href={`/products/${p.slug}`}>
{p.image_url ? (
<img src={p.image_url} alt={p.name} style={{ width: "100%", borderRadius: 8 }} />
) : null}
<div>{p.name}</div>
<div>{formatCents(p.variants[0]?.price_cents)}</div>
</Link>
</li>
))}
</ul>
</main>
);
}
export function formatCents(cents?: number) {
if (cents == null) return "";
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
}Money is integer cents. Every price on the platform — price_cents,
total_cents, card_charged_cents — is an integer number of cents. Convert to
a display string only at the edge, as formatCents does. Never store or math a
float dollar amount.
Build the product detail page
A dynamic route by slug. products.get throws FlightdeckError with
status: 404 for an unknown slug — distinguish that real “no such product” from
any other failure, and never swallow an error into an empty page.
import { notFound } from "next/navigation";
import { store, formatCents } from "@/lib/store";
import { FlightdeckError } from "@dscodotco/sdk";
import { AddToCart } from "./add-to-cart";
export const dynamic = "force-dynamic";
export default async function ProductPage({ params }: { params: { slug: string } }) {
let product;
try {
product = await store.products.get(params.slug);
} catch (err) {
if (err instanceof FlightdeckError && err.status === 404) return notFound();
throw err; // a transport/5xx error is real — do not render an empty product
}
return (
<main style={{ maxWidth: 720, margin: "0 auto", padding: 24 }}>
{product.image_url ? <img src={product.image_url} alt={product.name} style={{ maxWidth: "100%" }} /> : null}
<h1>{product.name}</h1>
{product.description ? <p>{product.description}</p> : null}
<ul>
{product.variants.map((v) => (
<li key={v.id}>
{v.label ?? v.sku} — {formatCents(v.price_cents)}
<AddToCart variantId={v.id} name={`${product.name} ${v.label ?? ""}`.trim()} priceCents={v.price_cents} />
</li>
))}
</ul>
</main>
);
}Never turn a thrown error into an empty result. catch { return [] } tells
the shopper “there is nothing here” when the truth is “we could not find out.”
Narrow 404 (real absence) from everything else (rethrow or show an error
state). This is the single most important rule when consuming the SDK.
Add a cart
Keep the cart tamper-proof: store only { variantId, quantity } in a cookie,
never prices. Re-price from the live catalog on every read, so a discontinued
line or a changed price can never be spoofed by a client. Here we use a small
client-side cart backed by localStorage for simplicity; the important
invariant is that the server prices the order at checkout, not the cart.
"use client";
export function AddToCart({ variantId, name, priceCents }: { variantId: string; name: string; priceCents: number }) {
function add() {
const cart = JSON.parse(localStorage.getItem("cart") ?? "[]") as { variantId: string; quantity: number }[];
const existing = cart.find((l) => l.variantId === variantId);
if (existing) existing.quantity += 1;
else cart.push({ variantId, quantity: 1 });
localStorage.setItem("cart", JSON.stringify(cart));
// Display name/price are for the client UI only — the server re-prices at checkout.
}
return <button onClick={add}>Add to cart</button>;
}The cart holds variantId and quantity only. Do not trust a client-supplied
price anywhere: checkout.submit sends only { variant_id, quantity } and the
platform computes the authoritative total from its own catalog prices, plus
shipping and tax. There is no client-total field to send.
Build the cart page
A client component that reads the cart and posts it to your checkout Route Handler. The card fields are collected here but are sent to your server, which holds the token — the browser never talks to the commerce API.
"use client";
import { useEffect, useState } from "react";
type Line = { variantId: string; quantity: number };
export default function CartPage() {
const [cart, setCart] = useState<Line[]>([]);
const [status, setStatus] = useState<string>("");
useEffect(() => {
setCart(JSON.parse(localStorage.getItem("cart") ?? "[]"));
}, []);
async function checkout(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
setStatus("Placing order…");
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
items: cart.map((l) => ({ variant_id: l.variantId, quantity: l.quantity })),
email: form.get("email"),
card: { ccnumber: form.get("ccnumber"), ccexp: form.get("ccexp"), cvv: form.get("cvv") },
}),
});
const json = await res.json();
if (res.ok) {
localStorage.removeItem("cart");
setStatus(`Order ${json.orderNumber} placed — charged ${json.charged}`);
} else {
setStatus(`Declined: ${json.message ?? "try another card"}`);
}
}
return (
<main style={{ maxWidth: 480, margin: "0 auto", padding: 24 }}>
<h1>Cart ({cart.length} lines)</h1>
<form onSubmit={checkout}>
<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" disabled={cart.length === 0}>Place order</button>
</form>
<p>{status}</p>
</main>
);
}Wire the checkout Route Handler
This is where the token lives and where the SDK call happens. One shot: cart +
card together. The Idempotency-Key is required and must be
deterministic — derive it from the business event so a retry replays instead
of double-charging.
import { NextResponse } from "next/server";
import { store } from "@/lib/store";
import { FlightdeckError } from "@dscodotco/sdk";
export async function POST(req: Request) {
const { items, email, card } = await req.json();
// Deterministic per business event — reuse across retries of THIS attempt.
// A real cart would carry a stable id; a content hash is a reasonable stand-in.
const idempotencyKey = `cart-${hash(items)}:attempt-1`;
try {
const result = await store.checkout.submit(
{
customer_ref: `guest:${email}`,
email,
items,
card,
},
{ idempotencyKey },
);
return NextResponse.json({
orderId: result.order.id,
orderNumber: result.order.order_number,
charged: `$${(result.card_charged_cents / 100).toFixed(2)}`,
outcome: result.outcome, // "placed" (201) or "already_placed" (200 replay)
});
} catch (err) {
if (err instanceof FlightdeckError) {
// e.g. err.body.error.code === "payment_declined" — do NOT auto-retry.
const body = err.body as { error?: { code?: string; message?: string } } | undefined;
return NextResponse.json({ message: body?.error?.message ?? "declined" }, { status: err.status });
}
throw err; // a real failure — never a fake success
}
}
function hash(x: unknown) {
const s = JSON.stringify(x);
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h).toString(36);
}A random idempotency key defeats the safety it exists for. A fresh random value per click means a network retry places a second order. Derive the key from the cart/attempt and reuse it across retries of that attempt. See Checkout.
Handle every checkout outcome
The submit call has more than “success”. Read all of them:
- Placed (201) —
outcome: "placed". The order and totals are onorder(order.id,order.order_number,order.total_cents);card_charged_centsis what actually hit the card after store credit and gift cards. - Replayed (200) — the same idempotency key seen again returns the original
order with
outcome: "already_placed"andreplayed: true. Treat it as success; do not place again. - Declined — a payment or fraud decline is a 4xx, so the SDK throws
FlightdeckError(err.body.error.code === "payment_declined"). Show a “try another card” state; do not retry automatically. - Error — any other thrown failure is real. Surface it; never pretend the order placed.
Run it
npm run devOpen localhost:3000, browse the catalog, open a
product, add it to the cart, and place a test order. Use the platform’s test
card values from your operator. A 201 confirms the full loop: catalog read,
PDP, cart, and a real server-side order placement.
PCI scope — a plain statement
Your checkout Route Handler receives raw card fields (ccnumber, ccexp,
cvv) and forwards them to the API. Any server that touches those fields is
inside PCI-DSS scope (SAQ D territory), because cardholder data transits a system
you operate. That is a fact of this integration shape, not a defect: keep card
data on the browser-to-your-HTTPS-handler-to-API path, never log or store it, and
confirm your obligations with your acquirer before going live. Full detail in
Checkout.
Where to go next
- Search and facets: add
store.search({ q, limit }). Note the known price filter drift — see SDK reference. Then read Search and discovery. - Store credit and gift cards: read a balance with
store.storeCredit.balance({ customer_ref })and passapply_store_credit/gift_card_codeat checkout. - Shopper accounts: show order history with
store.shopper.orders({ person_id })(theperson_idis required) once you have authenticated sessions — see Accounts & orders. - Subscribe and save: add subscriptions at checkout — see Add subscriptions.
- Deploy it: ship to Vercel and attach a domain — see Deploying and Custom domains.
- The site manifest: drive nav, hero, and theme from
store.site.get()instead of hard-coding — see Site builder.