GuidesTutorialsBuild a storefront from scratch

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-token credential: 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 originhttps://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/sdk

Accept 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.

.env.local
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-only
lib/store.ts
import "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.

app/page.tsx
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.

app/products/[slug]/page.tsx
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.

app/products/[slug]/add-to-cart.tsx
"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.

app/cart/page.tsx
"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.

app/api/checkout/route.ts
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 on order (order.id, order.order_number, order.total_cents); card_charged_cents is 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" and replayed: 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 dev

Open 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 pass apply_store_credit / gift_card_code at checkout.
  • Shopper accounts: show order history with store.shopper.orders({ person_id }) (the person_id is 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.