Next.js (App Router)
This guide takes you from an empty Next.js app to a working storefront on the Flightdeck commerce platform: a catalog grid, a product detail page (PDP), a client cart, and a real checkout. It uses the App Router, React Server Components for reads, and a Route Handler as the BFF that holds the checkout credential.
You are building against a store an operator has already provisioned — a tenant, its catalog and collections, and a storefront token issued to you. The SDK is the consumption surface for that store; you do not create products or mint tokens from here (see what this guide does not cover).
Want the whole thing already wired? The
DSCO-Co/nextjs-commerce fork
is Vercel’s Next.js Commerce with its only commerce dependency swapped for
@dscodotco/sdk. Clone it for a complete reference; read this guide to
understand each piece so you can build your own. The
headless storefront tutorial walks the fork
clone-to-live.
The one rule
The storefront token — an opaque secret (no fixed prefix), provisioned by the
platform operator; platform-wide today — authorizes placing orders. It is
server-side only and must never reach the browser bundle. The API sends
no CORS headers by design, so a shopper’s browser cannot call it directly —
every read and write goes through your Next.js server. In App Router terms:
construct the client in server components, route handlers, and server actions —
never in a "use client" module.
Before you start
- Node 18+ and a package manager (
pnpm,npm, oryarn). - A tenant ref (the public demo uses
ruo-demo). - A storefront token — an opaque secret (no fixed prefix), provisioned by the platform operator; platform-wide today.
- The API origin,
https://api.ruo.pro.
Create the app and install the SDK
npx create-next-app@latest my-store --ts --app --eslint
cd my-store
npm install @dscodotco/sdkSet the environment variables
# The commerce API origin.
FLIGHTDECK_API_URL="https://api.ruo.pro"
# The tenant this deployment serves.
FLIGHTDECK_TENANT="ruo-demo"
# The x-storefront-token credential. SERVER-SIDE ONLY — no NEXT_PUBLIC_ prefix.
FLIGHTDECK_STOREFRONT_TOKEN="your-storefront-token"Never prefix the token NEXT_PUBLIC_. Anything with that prefix is inlined
into the client bundle. The three variables above are read only in server
code.
Construct the client once, server-side
import "server-only";
import { createStorefrontClient } from "@dscodotco/sdk";
// `server-only` makes importing this from a client component a build error —
// a hard guard against leaking the token.
export const store = createStorefrontClient({
apiUrl: process.env.FLIGHTDECK_API_URL!,
tenant: process.env.FLIGHTDECK_TENANT!,
storefrontToken: process.env.FLIGHTDECK_STOREFRONT_TOKEN!,
});Render the catalog in a server component
Server components run on your server, so calling the SDK from them keeps the token off the client. Catalog reads are dynamic — mark the route so it reads at request time rather than caching stale prices at build.
import Link from "next/link";
import { store } from "@/lib/store";
export const dynamic = "force-dynamic";
export default async function Home() {
const { products } = await store.products.list();
return (
<ul>
{products.map((p) => (
<li key={p.id}>
<Link href={`/product/${p.slug}`}>
{p.image_url ? <img src={p.image_url} alt={p.name} width={240} /> : null}
<span>{p.name}</span>
</Link>
</li>
))}
</ul>
);
}Products carry image_url directly, so you render art from the same response —
no separate asset lookup.
Build the product detail page
Use notFound() for a real 404, but never turn a transport error into an empty
page. A failed read is not “no such product”.
import { notFound } from "next/navigation";
import { store } 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 } }) {
try {
const { product } = await store.products.get(params.slug);
const variant = product.variants[0];
return (
<main>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>${(variant.price_cents / 100).toFixed(2)}</p>
<AddToCart variantId={variant.id} name={product.name} priceCents={variant.price_cents} />
</main>
);
} catch (err) {
if (err instanceof FlightdeckError && err.status === 404) notFound();
throw err; // 5xx / transport — do NOT render an empty page
}
}Keep the cart on the client
The platform has no server-side cart and checkout is one-shot, so the cart is
client state. Store only { variantId, quantity } and re-price from the live
catalog — never trust a client-held price.
"use client";
import { useState } from "react";
export default function AddToCart(props: { variantId: string; name: string; priceCents: number }) {
const [added, setAdded] = useState(false);
function add() {
const cart = JSON.parse(localStorage.getItem("cart") ?? "[]");
cart.push({ variantId: props.variantId, quantity: 1 });
localStorage.setItem("cart", JSON.stringify(cart));
setAdded(true);
}
return <button onClick={add}>{added ? "Added" : "Add to cart"}</button>;
}Add the checkout route handler (the BFF)
Card fields flow browser -> your handler -> the SDK, never to a third party from the browser. The idempotency key is deterministic so a retry replays instead of re-charging.
import { store } from "@/lib/store";
import { FlightdeckError } from "@dscodotco/sdk";
export async function POST(request: Request) {
const { cartId, attempt, items, card, email } = await request.json();
try {
const result = await store.checkout.submit(
{
customer_ref: `guest:${email}`,
items, // [{ variant_id, quantity }]
card, // { ccnumber, ccexp, cvv }
email,
ip: request.headers.get("x-forwarded-for") ?? undefined,
},
{ idempotencyKey: `cart-${cartId}:attempt-${attempt}` },
);
return Response.json({ orderId: result.order.id, charged: result.card_charged_cents });
} catch (err) {
if (err instanceof FlightdeckError) {
return Response.json({ error: err.body }, { status: err.status });
}
throw err;
}
}See Integrate checkout for the full body (coupons, store credit, gift cards, the fraud floor) and the success shapes.
Deploy to Vercel
- Push the repo to GitHub and New Project in Vercel (Next.js is auto-detected).
- Under Settings > Environment Variables, add
FLIGHTDECK_API_URL,FLIGHTDECK_TENANT, andFLIGHTDECK_STOREFRONT_TOKENfor Production (and Preview if you want preview deploys to hit the same store). Mark the token as a secret. - Deploy. The store renders on the
*.vercel.appURL immediately — the tenant is env-pinned, so no custom domain is required to go live.
What this guide does not cover
These are honest platform boundaries, not omissions:
- Catalog writes. Products, variants, and prices are authored by the merchant in the console; the storefront SDK is read plus checkout only.
- Collections are operator-managed; you consume them, you do not define them from the storefront.
- The storefront token is operator-provisioned — an opaque secret (no fixed
prefix), platform-wide today. A developer receives it from the operator; there
is no self-serve mint for it. (Merchant
fdk_keys, by contrast, are self-serve — but they are for the merchant console, not this storefront BFF.)
Related
- SDK reference — every namespace and method.
- Integrate checkout — the full checkout flow.
- Build your own frontend — the BFF pattern in any framework.