Headless storefront with Next.js

Scaffold a Next.js storefront wired to a Brainerce store, covering catalog, cart, checkout, customer accounts and webhooks.

A "headless" storefront is one where Brainerce handles every commerce concern (catalog, cart, checkout, payment, orders, fulfillment) and you build the customer-facing UI on top. The create-brainerce-store scaffolder wires a Next.js app to a Brainerce store with sensible defaults.

Prerequisites

  • A Brainerce account with at least one store and one published product
  • Node.js 20+ and pnpm/npm/yarn
  • A code editor

1. Scaffold

npx create-brainerce-store@latest my-shop
cd my-shop
pnpm install

The scaffolder is interactive. It asks for the sales channel ID (or fetches one if you authenticate against your Brainerce account inside the prompt), picks a theme (luxury, minimal, or playful), and writes a .env.local with the sales-channel ID populated.

What you get (Next.js App Router shape):

my-shop/
├── src/
│   ├── app/                  Routes (page.tsx, products/, cart/, checkout/, account/, login/, register/…)
│   ├── components/           UI by area (cart/, checkout/, auth/, account/, products/, layout/)
│   ├── hooks/
│   ├── lib/                  brainerce.ts client + helpers
│   ├── providers/
│   └── middleware.ts
├── messages/                 i18n strings
└── .env.local                NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID etc.

2. Run the dev server

pnpm dev

Open http://localhost:3000. The product grid populates from your Brainerce store; click through to detail pages, add to cart, walk the checkout.

3. The SDK client

src/lib/brainerce.ts exports a pre-configured client wired with the sales-channel mode:

import { BrainerceClient } from 'brainerce';

export const client = new BrainerceClient({
  salesChannelId: process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID!,
});

That client gets you public reads (products, categories, brands) and write access scoped to a shopping session (cart, checkout, customer signup). Admin operations (bulk product import, inventory sync from your ERP) use a separate API-key client, covered in step 7.

4. List products

// src/app/products/page.tsx
import { client } from '@/lib/brainerce';

export default async function ProductsPage() {
  const { data: products } = await client.getProducts({ limit: 20 });

  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>
          <a href={`/products/${p.slug}`}>
            <img src={p.images?.[0]?.url} alt={p.name} />
            <h3>{p.name}</h3>
            <p>${p.salePrice ?? p.basePrice}</p>
          </a>
        </li>
      ))}
    </ul>
  );
}

The SDK call hits /api/vc/{salesChannelId}/products under the hood. Pagination and filtering flow through the standard { data, meta } envelope.

5. Cart

The SDK takes a cartId argument on every cart mutation, so your storefront persists the active cart id (cookie or local state). The minimum shape:

import { client } from '@/lib/brainerce';

// Add an item — `addToCart` takes the cart id + item details.
const updatedCart = await client.addToCart(cartId, {
  productId: 'prod_abc',
  variantId: 'var_xyz',
  quantity: 1,
});

// Read the cart back later.
const cart = await client.getCart(cartId);

The scaffolded cart UI under src/components/cart/ already handles this: cart-item.tsx, cart-summary.tsx and coupon-input.tsx are wired to the SDK.

6. Checkout

The Brainerce checkout is a state machine driven by separate SDK calls. The scaffolded multi-step form lives under src/components/checkout/ (shipping-step.tsx, payment-step.tsx, etc.); the minimum SDK shape is:

// Create a checkout from a cart.
const checkout = await client.createCheckout({ cartId });

// Attach customer + addresses. Include an optional "Order notes" textarea
// on the checkout page by default — its value lands on the order.
await client.setCheckoutCustomer(checkout.id, {
  email,
  firstName,
  lastName,
  notes: orderNotes, // optional shopper note, shown to the merchant
});
await client.setShippingAddress(checkout.id, address);
await client.setBillingAddress(checkout.id, billingAddress);

// Shipping.
const rates = await client.getShippingRates(checkout.id);
await client.selectShippingMethod(checkout.id, rates[0].id);

// Coupon (optional).
await client.applyCheckoutCoupon(checkout.id, 'SUMMER25');

// Gift card (optional, when features.hasGiftCards). A tender, NOT a discount:
// `total` does not move and tax stays on the full value — `providerAmountDue` drops.
const { tenderId, amountApplied, providerAmountDue } = await client.applyGiftCard(
  checkout.id,
  giftCardCode
);
await client.removeGiftCard(checkout.id, tenderId); // by tenderId, never by code

Render a gift card below the total, not in the discount block. checkout.total is unchanged by a card and is what tax was calculated on; checkout.providerAmountDue is what the provider will be charged, and checkout.tenders holds the applied cards ({ tenderId, amountApplied }[]) — read them from the checkout so they survive a reload. Apply and remove before you create the payment intent: afterwards both fail with CHECKOUT_LOCKED. Every refused code returns the same 400 with the same message, deliberately, so show one "we can't use this code" rather than a reason.

A checkout expires 30 minutes after it is created, from creation, not from last activity, and it isn't configurable. Past that, completing throws 400 Checkout session has expired, and the reserved inventory is released. Read the deadline off checkout.expiresAt, and don't design a step that waits on a human.

To recover, create a new checkout from the same cartId, since a cart can back more than one checkout. Keep that id on your side: expiry also marks the cart ABANDONED (unless the shopper has touched it since), and the SDK drops an ABANDONED cart on the next fetch.

Payment

Payment is embedded in your storefront, not a redirect to a hosted page. createPaymentIntent takes the checkout id as its first positional argument and returns a clientSecret that you hand to the provider's own frontend SDK:

const intent = await client.createPaymentIntent(checkout.id, {
  successUrl: `${window.location.origin}/order-confirmation?checkout_id=${checkout.id}`,
  cancelUrl: `${window.location.origin}/checkout?error=cancelled`,
});

There is no provider option. To route the charge to a specific installed provider (an additive express button, say), pass providerId, which is the id of an entry from getPaymentProviders(), not a provider name like 'stripe':

const { providers } = await client.getPaymentProviders();

// `isAdditive` providers (PayPal, wallets) render as express buttons ABOVE
// the card form; the primary card processor has methodType 'CREDIT_CARD'.
const express = providers.filter((p) => p.isAdditive);
const intent = await client.createPaymentIntent(checkout.id, { providerId: express[0].id });

Omit it and the charge settles through the store's default card processor.

You can also ask how the provider's surface is presented. preferredRenderType: 'iframe' | 'redirect' is honoured when the provider lists that mode in its clientSdk.displayModes, and falls back to the provider default otherwise (intent.renderModeResolution tells you which). Predict it first with resolveRenderType(provider.clientSdk, 'iframe') so the successUrl you pass matches: an iframe intent returns the shopper inside the frame to a same-origin page, a redirect intent returns them to your confirmation page. Then branch on what came back.

What you do with the intent depends on the provider. Branch on intent.clientSdk.renderType, and whenever you need a URL or a render argument read intent.clientSdk.renderArg first with intent.clientSecret only as the fallback. clientSecret holds the provider's payment identifier; only some providers duplicate the URL into it, which is why reading it alone passes in testing and fails on MAX and Takbull. Full note in Core, Step 5.3.

if (intent.provider === 'grow') {
  // Wallet / SDK providers: the render argument is
  // intent.clientSdk.renderArg || intent.clientSecret.
  // Render their widget, then tell the backend it succeeded — this is what
  // triggers order creation for SDK-based providers.
  await client.confirmSdkPayment(checkout.id, providerResponseData);
} else {
  // Stripe: clientSecret drives Stripe Elements in your own page.
  const { error } = await stripe.confirmPayment({
    elements,
    clientSecret: intent.clientSecret,
    confirmParams: { return_url: `${window.location.origin}/order-confirmation` },
  });
  if (error) throw new Error(error.message);
}

After payment: the webhook creates the order

You do not call completeCheckout() after a real payment. The provider's webhook fires server-to-server and creates the order in the same transaction that marks the payment captured. Your job is to wait for it and then read the result:

// Polls with exponential backoff until the order exists (default 30s max).
const result = await client.waitForOrder(checkout.id);

if (!result.success) {
  // Timed out — the payment may still land. Show a "we're confirming your
  // order" state rather than an error, and let the webhook finish the job.
}

// `result.status` is a PaymentStatus OBJECT, not a string:
const { orderId, orderNumber } = result.status;

// Clear the local cart state. Synchronous — no await.
client.handlePaymentSuccess(checkout.id);

getPaymentStatus(checkoutId) is the single-shot version if you'd rather drive the polling yourself.

Idempotency. Idempotency-Key is honoured on /v1 (API-key) mutating endpoints only, where POST /v1/checkout/{id}/complete in fact requires it. Storefront /vc/… calls, which is everything on this page, silently ignore the header, and the SDK never sends it. See /docs/api/idempotency.

Guest checkout

Shoppers don't need an account. The guest flow is the same machine with its own entry point:

// Creates a server-side cart from the SDK's tracked cart, then a checkout.
const started = await client.startGuestCheckout();
if (!started.tracked) throw new Error(started.message); // empty cart, etc.

await client.setCheckoutCustomer(started.checkoutId, { email, firstName, lastName });
await client.updateGuestCheckoutAddress(started.checkoutId, { shippingAddress: address });

// …then the same payment step as above.

The return value is a discriminated union, so checkoutId only exists once you've narrowed on tracked; so check it before destructuring.

getActiveGuestCheckout() returns the in-progress checkout if the shopper reloads the page (synchronous, with no await).

Guest orders attach themselves to a customer account automatically. When that shopper later registers or logs in with the same email address, Brainerce links their past guest orders to the new account. There is no API call to make and nothing for you to build.

7. Admin operations (your own scripts)

For operations that aren't shopper-initiated, such as bulk product import, inventory sync from your ERP, or back-office order lookups, use an admin API key (not the sales-channel id).

// scripts/sync-inventory.ts
import { BrainerceClient } from 'brainerce';

const admin = new BrainerceClient({ apiKey: process.env.BRAINERCE_API_KEY! });

for (const item of items) {
  await admin.updateInventory(item.productId, { total: item.stock });
}

Issue the key at Settings → Authentication → API Keys with the minimum scopes (inventory:write, products:read). Use a brainerce_test_* key for local testing, since it doesn't touch live customer data.

Refunds and fulfilment are not on the API. Neither has a /v1 endpoint, and both are dashboard-only actions. PATCH /v1/orders/{id} can move an order's status, but it will not move money or record carrier tracking. Don't plan a refund-from-CSV script around it.

8. Customer accounts

Customers can sign up directly on your storefront:

const { token, customer } = await client.registerCustomer({
  email: '[email protected]',
  password: 'CorrectHorse-Battery-Staple9',
  firstName: 'Jane',
  lastName: 'Doe',
});
client.setCustomerToken(token);

Or via OAuth (Google, and others). See the auth components under src/components/auth/ for the scaffolded UI and Social login (OAuth) for the protocol.

9. Webhooks (when your backend needs to react)

If your storefront has its own server-side logic that should run on order.created (send to fulfillment, update CRM, sync to accounting), subscribe to a webhook:

POST /api/stores/{storeId}/webhook-subscriptions
Content-Type: application/json
Authorization: Bearer brainerce_xxx

{
  "url": "https://my-shop.com/webhooks/brainerce",
  "events": ["order.created", "order.cancelled", "customer.created"]
}

Requires the webhooks:write scope on the API key (webhooks:read to list / view deliveries). These are store-scoped dashboard routes that also accept an API key, and they are not part of the /v1 public API surface, so you will not find them in the API reference. See Merchant Integration for the full set of subscription endpoints, retry behaviour and the circuit breaker, and the Event Catalogue for every event you can subscribe to.

Then verify the signature on every delivery. See Verify a webhook signature.

10. SSR-hydrated store config (currency, locale, flags)

The scaffolded <StoreProvider> is hydrated server-side with your store's currency, locale, free-shipping threshold, upsell flags, and i18n config. This means the very first byte Googlebot reads has the correct currency symbol (₪, €, £, … not always $) and your client components render with real settings from frame 0, with no fallback flash, no hydration mismatch, no SEO regression.

How it works:

  1. src/lib/brainerce.ts exposes fetchStoreInfo(locale?), a cache()-wrapped helper that calls the public /api/vc/:id/info endpoint with next: { revalidate: 60, tags: ['store-info'] }.
  2. src/app/[locale]/layout.tsx (or app/layout.tsx) awaits fetchStoreInfo() inside the existing Promise.all alongside the announcement / header / footer fetches.
  3. The result is passed to <StoreProvider initialStoreInfo={storeInfo}>.
  4. useStoreInfo() consumers see real data from render 0; the client-side useEffect fetch only runs as a fallback when the server returned null (network failure).

If your dashboard mutates store config (currency change, free-shipping threshold edit, upsell toggle), bust the cache so the next request re-fetches:

// On the storefront, expose an endpoint your dashboard webhook can POST to:
import { revalidateTag } from 'next/cache';

export async function POST(req: Request) {
  // verify HMAC signature first — see /docs/recipes/verify-webhook-signature
  revalidateTag('store-info');
  return new Response(null, { status: 204 });
}

Until you wire that, the worst case is a 60-second lag between dashboard save and storefront reflecting the change.

What's exposed to the client

The provider strips operational fields from the raw StoreInfo response (salesChannelStatus, allowedScopes, sandboxPaymentsEnabled, internal cuids, channel names) and exposes a PublicStoreInfo subset: name, currency, language, metaDescription, logo, contactEmail/Phone, socialLinks, requireEmailVerification, upsell.*, i18n.*. Add a field to src/lib/store-info.ts only after confirming it is non-sensitive and required by a storefront-side consumer.

11. Deploy

The scaffolder is a vanilla Next.js app, so deploy it to any host: Vercel, Netlify, Cloudflare Pages, Railway, or your own VPS. Env vars:

NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID=vc_xxx   # sales channel id (scaffolder fills this in)
BRAINERCE_API_KEY=brainerce_xxx                 # admin key — server-side only

Register your production domain in the Brainerce dashboard sidebar under SELL → Sales Channels → your channel → Allowed Origins. The platform enforces this on storefront API calls.