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
NEXT_PUBLIC_ means here. Never put a qb_pat_ token in this app.
2. The clients: lib/shopkit.ts
src/lib/shopkit.ts
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.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 clientProviders component.
src/app/layout.tsx
src/app/providers.tsx
initialCartmakes the badge correct on the first paint, with no hydration mismatch.consent.initialStateis 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.currencystarts the browser client in the currency the server rendered with, so the first paint agrees. See Currencies below.SeoProvidercarries what every page’s<SEO />would otherwise repeat. Itscurrencyis only a fallback: structured data uses each product’s ownproduct.currency.
4. Catalog page
src/app/page.tsx
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.
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
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 theqb_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
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:
- <SEO /> everywhere (recommended)
- generateMetadata
No
metadata export in layouts. Every page renders <SEO />, which emits meta tags and JSON-LD structured data.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
/_next/image into an open proxy that fetches any URL a visitor names.
Rendering mode
Every page here reads cookies, so the example marks themexport 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
Remix / React Router (framework mode)
Remix / React Router (framework mode)
In loaders and actions, build a per-request client from the request’s cookies and append the collected Client side:
Set-Cookie headers to the response:<ShopkitProvider config> with initialCart read in the root loader via cart.current().TanStack Start
TanStack Start
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().React 18
React 18
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
metadataexport competing with<SEO />. -
NEXT_PUBLIC_SITE_URLis fixed in production; storefront andsuccessUrlshare an origin. - Only the
qb_pk_key is in the app. - The layout passes
shopkit.consent.read()asconsent.initialState, there is a privacy policy page, and the footer has a<ConsentSettingsButton>. - Cached catalog data is keyed on the currency.