GuidesFrameworksNext.js

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, or yarn).
  • 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/sdk

Set the environment variables

.env.local
# 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

lib/store.ts
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.

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

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

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

app/api/checkout/route.ts
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

  1. Push the repo to GitHub and New Project in Vercel (Next.js is auto-detected).
  2. Under Settings > Environment Variables, add FLIGHTDECK_API_URL, FLIGHTDECK_TENANT, and FLIGHTDECK_STOREFRONT_TOKEN for Production (and Preview if you want preview deploys to hit the same store). Mark the token as a secret.
  3. Deploy. The store renders on the *.vercel.app URL 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.)