Skip to main content
This page is the complete wiring of a Next.js storefront: catalog, product page with a variant picker, cart, server-action checkout and a thank-you page that confirms the order. It follows the kit-nextjs example that ships with the kit.

The shape

All of them remember the cart in the same qb_cart_id cookie, so the server and the browser always see the same cart. The same goes for the shopper’s consent decision (qb_consent) and their currency (qb_currency).

1. Install and configure

.env.local
The publishable key is designed to ship to the browser, which is what NEXT_PUBLIC_ means here. Never put a qb_pat_ token in this app.

2. The clients: lib/shopkit.ts

src/lib/shopkit.ts
A server component cannot set cookies. Call cart.current() there (it never creates a cart) and keep cart.add(), cart.ensure() and checkout.start() in a server action, a route handler or the browser.
Do not mark the kit’s cookies httpOnly. The browser half of the app reads and clears them; an httpOnly cart id is invisible to useCart.
Cookie consent is on by default, and its settings live in the config so the server read, the browser provider and the checkout handoff can never disagree. To bump the policy revision, add consent: { revision: 2 } to baseConfig(); to turn consent and analytics off entirely, consent: false. See Consent and analytics.

3. Layout and providers

The layout reads the shop, the cart, the shopper’s consent decision and their currency on the server, in parallel, and hands them to a client Providers component.
src/app/layout.tsx
src/app/providers.tsx
  • initialCart makes the badge correct on the first paint, with no hydration mismatch.
  • consent.initialState is the decision the server read, so the cookie banner’s first render already matches the shopper’s earlier choice. The provider shows the banner and loads the shop’s own GA4, GTM and Meta pixel only after the shopper agrees.
  • currency starts the browser client in the currency the server rendered with, so the first paint agrees. See Currencies below.
  • SeoProvider carries what every page’s <SEO /> would otherwise repeat. Its currency is only a fallback: structured data uses each product’s own product.currency.

4. Catalog page

src/app/page.tsx
Keep filter state in the URL: the server renders page one (crawlers and shared links see real products) and the back button behaves. Load further pages with a server action that calls products.search({ …, cursor }). Price filters (minPrice, maxPrice, sortBy: "price") always run in the shop’s own currency, so label them with shop.currency, not the currency the cards are shown in.

5. Product page

Start the lookup on the server without awaiting it and hand the promise to <ProductProvider>. The shell streams immediately, the request is already in flight while the client bundle loads, and the product ends up in the streamed HTML.
src/app/products/[slug]/page.tsx
<ProductView /> is a client component that reads everything from the provider: see the variant picker example. After an addToCart() resolves, call router.refresh() if server-rendered parts of the page show cart data; the badge updates itself from the shared store.
getBySlug pages through the catalog (there is no slug filter). On a large catalog wrap it in React’s cache() with a revalidate window, or build a slug-to-id map from listAll() at deploy time and look up by id.

6. Checkout as a server action

A server action is the right place to start the checkout: cookies are writable (so the session id is remembered for the return leg), and the redirect is a real 303.
src/app/cart/actions.ts
src/components/checkout-form.tsx
The cart page itself is a client component on useCart(); see Cart page. There is no consent or currency code in the action. The writable client reads the qb_consent cookie and appends the shopper’s decision to the hosted checkout URL (plus the Google Analytics client and session ids when analytics is granted, which is what getAll is for). It also reads qb_currency, so the checkout opens in the shopper’s currency.

7. Thank-you page

The hosted checkout returns the shopper to <successUrl origin>/success/<orderNumber>, whatever path successUrl carried, so the route must be exactly app/success/[orderNumber]. Take the first confirmation snapshot on the server and hand it to useOrderConfirmation. The checkout usually waits for the order before redirecting, so most shoppers see the final state on first paint with no spinner. A terminal snapshot is final: the hook does not poll, forgets the bought cart and session, and fires the purchase analytics event from it. Only a still-processing order polls.
src/app/success/[orderNumber]/page.tsx
src/components/order-confirmation.tsx
timeout is not a failure: never tell a paying customer their payment failed on it. The purchase event is deduplicated across reloads, so a refreshed thank-you page reports nothing twice; pass trackPurchase: false to report it yourself.

Currencies

When the shop offers more than one currency, the shopper’s choice lives in the qb_currency cookie. Every client with a cookie accessor reads it on its own, so the cart and the checkout follow the choice with no code. Two places need the value itself, which is what requestCurrency() above is for: the cookie-less catalogShopkit(currency) and the provider’s currency prop. A client-side switcher calls useCurrency().setCurrency(), which writes the cookie and re-reads the cart, then refreshes the route so server components fetch in the new currency:
src/components/currency-switcher.tsx
Anything that caches catalog data (cache(), unstable_cache, a CDN key) must key on the currency, or one shopper’s euro prices are served to the next shopper’s kronor page.
Display versus charge currencies, the cart’s presentment and the checkout’s behaviour are covered in Currencies.

SEO: pick one owner

<SEO /> hoists <title>, meta and link tags into <head> on React 19. A metadata export in a layout does not get overridden by it; the two compete and the page ships two <title> elements. Choose one:
Use a fixed NEXT_PUBLIC_SITE_URL for canonical URLs. Deriving the origin from the request’s Host header is convenient locally and a known SEO poisoning vector in production.
More in SEO.

Images

<ProductImage /> renders a plain <img> with the CDN URL resolved, so no Next.js image config is needed. If you switch to next/image with useProductImage(), add the shop’s image host to images.remotePatterns:
next.config.ts
Never add a wildcard pattern: it turns /_next/image into an open proxy that fetches any URL a visitor names.

Rendering mode

Every page here reads cookies, so the example marks them export const dynamic = "force-dynamic". Catalog reads through catalogShopkit(currency) touch no cookies and can be cached, as long as the cache key includes the currency; make that decision per route.

Other React frameworks

In loaders and actions, build a per-request client from the request’s cookies and append the collected Set-Cookie headers to the response:
Client side: <ShopkitProvider config> with initialCart read in the root loader via cart.current().
Same pattern inside server functions (createServerFn): build the client with createRequestCookieStorage from the current request and set the collected cookies on the response. Use buildSeo() / useSeo() in a route’s head().
Everything works except the slug / id / promise forms of <ProductProvider> and <SEO /> head hoisting. Resolve the product yourself, pass product={product}, and feed useSeo() into your framework’s head mechanism.

Checklist

  • /success/[orderNumber] exists and confirms against the API, noIndex noFollow.
  • checkout.start() runs only where cookies are writable.
  • Cookies are not httpOnly.
  • No layout metadata export competing with <SEO />.
  • NEXT_PUBLIC_SITE_URL is fixed in production; storefront and successUrl share an origin.
  • Only the qb_pk_ key is in the app.
  • The layout passes shopkit.consent.read() as consent.initialState, there is a privacy policy page, and the footer has a <ConsentSettingsButton>.
  • Cached catalog data is keyed on the currency.