> ## Documentation Index
> Fetch the complete documentation index at: https://quickbutik.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Next.js App Router

> The reference architecture for a Quickbutik storefront on the Next.js App Router: server components for reads, server actions for writes, client hooks for the cart.

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

| Where | Client | Can |
| - | - | - |
| Server components (layouts, pages) | `readOnlyShopkit()`: reads cookies, cannot write them | Read the catalog, `cart.current()`, `checkout.currentSessionId()` |
| Server actions, route handlers | `writableShopkit()`: reads and writes cookies | `cart.add()`, `checkout.start()` |
| Catalog reads | `catalogShopkit(currency)`: no cookies at all | Products, categories; free to memoize per currency |
| The browser | `<ShopkitProvider config>` | `useCart`, `<ProductProvider>`, `useOrderConfirmation`, the consent banner |

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

```bash theme={null}
npx create-next-app@latest my-shop
cd my-shop
npm install @quickbutik/kit
```

```bash .env.local theme={null}
NEXT_PUBLIC_QUICKBUTIK_PUBLISHABLE_KEY=qb_pk_…
NEXT_PUBLIC_SITE_URL=https://myshop.com
```

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`

```ts src/lib/shopkit.ts theme={null}
import { createShopkitClient, normalizeCurrency, type ShopkitClient } from "@quickbutik/kit"
import { cookies } from "next/headers"

function baseConfig() {
  const publishableKey = process.env.NEXT_PUBLIC_QUICKBUTIK_PUBLISHABLE_KEY
  if (!publishableKey) throw new Error("NEXT_PUBLIC_QUICKBUTIK_PUBLISHABLE_KEY is not set")
  return { publishableKey }
}

/** The browser's config, handed to <ShopkitProvider>. A plain object: a client cannot be serialized. */
export function clientConfig() {
  return baseConfig()
}

/** Server COMPONENTS: read-only cookies. Writes are silently dropped. */
export async function readOnlyShopkit(): Promise<ShopkitClient> {
  const jar = await cookies()
  return createShopkitClient({
    ...baseConfig(),
    cookies: { get: (name) => jar.get(name)?.value ?? null },
  })
}

/**
 * Catalog reads: no cookies, so the result is the same for every visitor IN THE SAME CURRENCY.
 * Anything that memoizes what it returns must key on `currency` too.
 */
export function catalogShopkit(currency: string | null): ShopkitClient {
  return createShopkitClient({ ...baseConfig(), currency, storage: "memory" })
}

/** The shopper's remembered currency (the `qb_currency` cookie), or null for the shop's own. */
export async function requestCurrency(): Promise<string | null> {
  const value = (await cookies()).get("qb_currency")?.value
  try {
    return normalizeCurrency(value)   // a tampered cookie reads as "none"
  } catch {
    return null
  }
}

/** Server ACTIONS and route handlers: writable cookies. */
export async function writableShopkit(): Promise<ShopkitClient> {
  const jar = await cookies()
  return createShopkitClient({
    ...baseConfig(),
    cookies: {
      get: (name) => jar.get(name)?.value ?? null,
      // Lets checkout.start() forward the shopper's Google Analytics ids
      // (`_ga_<property>`, a name the kit cannot know in advance) with the consent decision.
      getAll: () => jar.getAll(),
      set: (name, value, attributes) => {
        jar.set(name, value, {
          path: attributes.path ?? "/",
          maxAge: attributes.maxAge,
          sameSite: attributes.sameSite ?? "lax",
          secure: attributes.secure ?? false,
          // NOT httpOnly: the browser half (useCart) reads and clears the same cookies.
          httpOnly: false,
        })
      },
      remove: (name) => {
        jar.delete(name)
      },
    },
  })
}
```

<Warning>
  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.
</Warning>

<Note>
  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`.
</Note>

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](/kit/concepts/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.

```tsx src/app/layout.tsx theme={null}
import { ConsentSettingsButton } from "@quickbutik/kit/react"
import { CartBadge } from "@/components/cart-badge"
import { CurrencySwitcher } from "@/components/currency-switcher"
import { clientConfig, readOnlyShopkit, requestCurrency } from "@/lib/shopkit"
import { Providers } from "./providers"

// Every page reads cookies (for the cart) and live catalog data.
export const dynamic = "force-dynamic"

// No `metadata` export: every page renders <SEO />, which owns the title.

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const shopkit = await readOnlyShopkit()
  const [shop, cart, consent, currency] = await Promise.all([
    shopkit.shop.get().catch(() => null),
    shopkit.cart.current().catch(() => null),   // never creates a cart
    shopkit.consent.read(),                      // the qb_consent cookie, so the banner never flashes
    requestCurrency(),                           // the qb_currency cookie
  ])

  return (
    <html lang="sv">
      <body>
        <Providers
          config={clientConfig()}
          initialCart={cart}
          shop={shop}
          siteUrl={process.env.NEXT_PUBLIC_SITE_URL!}
          consent={consent}
          currency={currency}
        >
          <header>
            <a href="/">{shop?.name}</a>
            <CurrencySwitcher />
            <CartBadge />
          </header>
          {children}
          <footer>
            {shop?.name}
            <a href="/integritetspolicy">Integritetspolicy</a>
            {/* Reopens the consent dialog after the banner has gone. */}
            <ConsentSettingsButton className="link" />
          </footer>
        </Providers>
      </body>
    </html>
  )
}
```

```tsx src/app/providers.tsx theme={null}
"use client"
import type { Cart, ConsentState, Shop, ShopkitConfig } from "@quickbutik/kit"
import { SeoProvider, ShopkitProvider } from "@quickbutik/kit/react"
import type { ReactNode } from "react"

export function Providers(props: {
  config: ShopkitConfig
  initialCart: Cart | null
  shop: Shop | null
  siteUrl: string
  consent: ConsentState
  currency: string | null
  children: ReactNode
}) {
  return (
    <ShopkitProvider
      config={props.config}
      initialCart={props.initialCart}
      consent={{ initialState: props.consent, lang: "sv", privacyPolicyUrl: "/integritetspolicy" }}
      currency={props.currency ?? undefined}
    >
      <SeoProvider
        baseUrl={props.siteUrl}
        shop={props.shop}
        currency={props.shop?.currency ?? undefined}
        productPath="/products/{slug}"
        organization
      >
        {props.children}
      </SeoProvider>
    </ShopkitProvider>
  )
}
```

* `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](#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

```tsx src/app/page.tsx theme={null}
import { SEO } from "@quickbutik/kit/react"
import { ProductGrid } from "@/components/product-grid"
import { catalogShopkit, requestCurrency } from "@/lib/shopkit"

export const dynamic = "force-dynamic"

export default async function HomePage({ searchParams }) {
  const { q, category, sort } = await searchParams
  const currency = await requestCurrency()
  const catalog = catalogShopkit(currency)

  const [page, categories] = await Promise.all([
    catalog.products
      .search({ search: q, categoryId: category, sortBy: sort, limit: 24 })
      .catch(() => ({ data: [], has_more: false, next_cursor: null })),
    catalog.categories.list({ root: true }).then((r) => r.data).catch(() => []),
  ])

  const filtered = Boolean(q || category || sort)

  return (
    <main>
      {/* Filter combinations multiply into near-duplicate URLs: don't index them. */}
      <SEO url="/" noIndex={filtered} />
      {/* Keyed on the currency: a switch drops pages priced in the old one. */}
      <ProductGrid key={currency ?? "base"} products={page.data} nextCursor={page.next_cursor} />
    </main>
  )
}
```

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.

```tsx src/app/products/[slug]/page.tsx theme={null}
import type { Product } from "@quickbutik/kit"
import { ProductProvider, SEO } from "@quickbutik/kit/react"
import { notFound, redirect } from "next/navigation"
import { Suspense } from "react"
import { ProductView } from "@/components/product-view"
import { readOnlyShopkit } from "@/lib/shopkit"

export const dynamic = "force-dynamic"

export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const shopkit = await readOnlyShopkit()

  // Old /products/prod_27 links: resolve by id and redirect to the slug.
  const asId = /^(?:prod_)?(\d+)$/.exec(slug)
  if (asId) {
    const byId = await shopkit.products.get(`prod_${asId[1]}`)
    if (!byId) notFound()
    if (byId.slug) redirect(`/products/${byId.slug}`)
    return <ProductShell product={byId} />
  }

  return <ProductShell product={shopkit.products.getBySlug(slug)} />
}

function ProductShell({ product }: { product: Product | Promise<Product | null> }) {
  return (
    <main>
      <Suspense fallback={<ProductSkeleton />}>
        <ProductProvider product={product} notFound={<Missing />}>
          <SEO />
          <ProductView />
        </ProductProvider>
      </Suspense>
    </main>
  )
}

function Missing() {
  // Streaming already answered 200: tell crawlers not to index this soft 404.
  return (
    <>
      <SEO title="Produkten finns inte" noIndex />
      <p>Produkten finns inte, eller är inte publicerad.</p>
    </>
  )
}
```

`<ProductView />` is a client component that reads everything from the provider: see the [variant picker example](/kit/react/catalog-and-products#a-complete-variant-picker). 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.

<Tip>
  `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.
</Tip>

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

```ts src/app/cart/actions.ts theme={null}
"use server"
import { redirect } from "next/navigation"
import { writableShopkit } from "@/lib/shopkit"

export async function startCheckoutAction(_previous: string | null): Promise<string | null> {
  const origin = process.env.NEXT_PUBLIC_SITE_URL!
  let url: string
  try {
    const shopkit = await writableShopkit()
    const result = await shopkit.checkout.start({
      successUrl: `${origin}/success`,   // only the ORIGIN is used
      cancelUrl: `${origin}/cart`,
      backUrl: `${origin}/`,             // the checkout's "continue shopping"
      language: "sv",
    })
    url = result.url
  } catch (error) {
    // Returned as state so the cart page can show it.
    return error instanceof Error ? error.message : "Could not start the checkout"
  }
  // Outside the try: redirect() signals by throwing, and a catch would swallow it.
  redirect(url)
}
```

```tsx src/components/checkout-form.tsx theme={null}
"use client"
import { useActionState } from "react"
import { useCart } from "@quickbutik/kit/react"
import { startCheckoutAction } from "@/app/cart/actions"

export function CheckoutForm() {
  const { pending } = useCart()
  const [error, startCheckout, starting] = useActionState(startCheckoutAction, null)
  return (
    <form action={startCheckout}>
      {error && <p role="alert">{error}</p>}
      <button type="submit" disabled={starting || pending > 0}>
        {starting ? "Preparing checkout…" : "Go to checkout"}
      </button>
    </form>
  )
}
```

The cart page itself is a client component on `useCart()`; see [Cart page](/kit/react/cart-and-checkout#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.

```tsx src/app/success/[orderNumber]/page.tsx theme={null}
import { SEO } from "@quickbutik/kit/react"
import { OrderConfirmation } from "@/components/order-confirmation"
import { readOnlyShopkit } from "@/lib/shopkit"

export const dynamic = "force-dynamic"

export default async function SuccessPage({ params }: { params: Promise<{ orderNumber: string }> }) {
  const { orderNumber } = await params
  const shopkit = await readOnlyShopkit()
  const sessionId = await shopkit.checkout.currentSessionId()
  const initialConfirmation = sessionId
    ? await shopkit.checkout.confirmation(sessionId).catch(() => null)
    : null

  return (
    <main>
      {/* One shopper's private page: never index it. */}
      <SEO title="Tack för din order" noIndex noFollow />
      <h1>Tack för din order!</h1>
      <OrderConfirmation
        orderNumberFromUrl={Number.parseInt(orderNumber, 10)}
        sessionId={sessionId}
        initialConfirmation={initialConfirmation}
      />
    </main>
  )
}
```

```tsx src/components/order-confirmation.tsx theme={null}
"use client"
import type { SessionConfirmation } from "@quickbutik/kit"
import { useOrderConfirmation } from "@quickbutik/kit/react"

export function OrderConfirmation({
  orderNumberFromUrl,
  sessionId,
  initialConfirmation,
}: {
  orderNumberFromUrl: number
  sessionId: string | null
  initialConfirmation: SessionConfirmation | null
}) {
  const { status, orderNumber, outcome, loading, error } = useOrderConfirmation(sessionId, {
    initialConfirmation,          // a completed or failed snapshot is final: no polling
    enabled: sessionId !== null,  // nothing to poll on a direct visit
  })
  const shown = orderNumber ?? orderNumberFromUrl  // the URL number is display-only

  if (status === "failed" || outcome?.kind === "failed") return <p>Betalningen gick inte igenom. Ingen order har skapats.</p>
  if (loading) return <p>Skapar din order…</p>
  if (error || outcome?.kind === "timeout") {
    return <p>Order #{shown}: din betalning är registrerad, ordern dyker upp inom kort.</p>
  }
  return <p>Order #{shown} är bekräftad. En orderbekräftelse är på väg till din e-post.</p>
}
```

`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:

```tsx src/components/currency-switcher.tsx theme={null}
"use client"
import { useCurrency } from "@quickbutik/kit/react"
import { useRouter } from "next/navigation"
import { useTransition } from "react"

export function CurrencySwitcher() {
  const { currency, currencies, setCurrency } = useCurrency()
  const router = useRouter()
  const [isPending, startTransition] = useTransition()
  if (currencies.length < 2) return null   // the shop offers only its own currency

  async function choose(code: string) {
    await setCurrency(code)
    startTransition(() => router.refresh())
  }

  return (
    <select value={currency ?? ""} disabled={isPending} onChange={(e) => void choose(e.target.value)}>
      {currencies.map((c) => <option key={c.code} value={c.code}>{c.code}</option>)}
    </select>
  )
}
```

<Warning>
  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.
</Warning>

Display versus charge currencies, the cart's `presentment` and the checkout's behaviour are covered in [Currencies](/kit/concepts/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:

<Tabs>
  <Tab title="<SEO /> everywhere (recommended)">
    No `metadata` export in layouts. Every page renders `<SEO />`, which emits meta tags and JSON-LD structured data.
  </Tab>

  <Tab title="generateMetadata">
    ```ts theme={null}
    import { buildSeo, toNextMetadata } from "@quickbutik/kit"
    import { JsonLd } from "@quickbutik/kit/react"
    import { readOnlyShopkit } from "@/lib/shopkit"

    export async function generateMetadata({ params }) {
      // Read in the shop's own currency: structured data must publish the price you charge.
      const shopkit = await readOnlyShopkit()
      const product = await shopkit.products.getBySlug((await params).slug, { currency: null })
      return toNextMetadata(buildSeo({ product, baseUrl: process.env.NEXT_PUBLIC_SITE_URL }))
    }
    // In the page: Next's Metadata has no JSON-LD slot, so render it yourself.
    // <JsonLd data={buildSeo({ product, ... }).jsonLd} />
    ```

    Or keep a layout `metadata` export and pass `skipTitle` to `<SEO />`.
  </Tab>
</Tabs>

<Warning>
  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.
</Warning>

More in [SEO](/kit/concepts/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`:

```ts next.config.ts theme={null}
const config = {
  images: {
    remotePatterns: [{ protocol: "https", hostname: "cdn.quickbutik.com" }],
  },
}
export default config
```

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

<AccordionGroup>
  <Accordion title="Remix / React Router (framework mode)">
    In loaders and actions, build a per-request client from the request's cookies and append the collected `Set-Cookie` headers to the response:

    ```ts theme={null}
    import { createShopkitClient, createRequestCookieStorage } from "@quickbutik/kit"
    import { redirect } from "react-router"

    const base = { publishableKey: process.env.QUICKBUTIK_PUBLISHABLE_KEY! }

    export async function action({ request }) {
      const setCookies: string[] = []
      const shopkit = createShopkitClient(base).withStorage(
        createRequestCookieStorage(request, { collect: setCookies, attributes: { secure: true, sameSite: "lax" } }),
      )
      const origin = new URL(request.url).origin
      const { url } = await shopkit.checkout.start({ successUrl: `${origin}/success`, backUrl: origin })
      const headers = new Headers()
      for (const cookie of setCookies) headers.append("set-cookie", cookie)
      return redirect(url, { headers })
    }
    ```

    Client side: `<ShopkitProvider config>` with `initialCart` read in the root loader via `cart.current()`.
  </Accordion>

  <Accordion title="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()`.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

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


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.