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

# Customer accounts

> Let shoppers log in on a headless storefront: customer-group prices, the customer club, sessions and renewal, and where to keep the session on a server.

A shop that has the customer-login app switched on can let customers log in on a headless storefront too. Once a customer is logged in, the client sends their token with **every** request without being asked. Products, categories, the cart and the checkout are then priced for the customer's group, and products visible only to that group show up. The order is linked to the account, which is what earns club points. Nothing else in the storefront changes.

<CodeGroup>
  ```tsx React theme={null}
  import { useCustomer, useCustomerClub } from "@quickbutik/kit/react"

  function Account() {
    const { customer, isLoggedIn, login, logout, error } = useCustomer()
    const { data: club } = useCustomerClub()   // null for a guest

    if (!isLoggedIn) return <LoginForm onSubmit={login} error={error} />
    return (
      <>
        <p>Hi {customer?.firstName}{club?.member && `, ${club.points.balance} points`}</p>
        <button onClick={logout}>Log out</button>
      </>
    )
  }
  ```

  ```ts Vanilla JS theme={null}
  await shopkit.customers.config()      // { login, customerGroups, customerClub }
  const { customer } = await shopkit.customers.login({ email, password })
  shopkit.customers.isLoggedIn          // true, synchronous
  await shopkit.products.list()         // group prices, no extra arguments
  await shopkit.customers.me()          // profile, address, group
  await shopkit.customers.club.get()    // club points and progress
  await shopkit.customers.logout()      // a guest again, with a fresh cart
  ```

  ```js Script tag theme={null}
  // The ambient client; every <qb-product>, <qb-product-list> and cart element reprices
  await Quickbutik.client.customers.login({ email, password })
  await Quickbutik.client.customers.logout()
  ```
</CodeGroup>

<Note>
  Customer accounts need kit **1.9.0** or later, and a publishable key with the `customer:session` scope. The key from the admin's **Custom storefront** view has it automatically. A key minted with an explicit list of scopes has it only if that list includes it; otherwise the platform refuses the calls with a `403`, whatever the kit's local scope check says. `customers.config()` also works with `storefront:read` alone. See [Publishable keys](/kit/publishable-keys#scopes).
</Note>

Registration, password reset, profile edits, order history and joining the club are not in the API yet. Send shoppers to the shop's own account pages for those.

## What the shop has switched on

`customers.config()` returns the merchant's settings. They're public and the same for every shopper, so the kit reads them once per client:

```ts theme={null}
const config = await shopkit.customers.config()
config.login.enabled                 // customer login is on
config.login.signupAllowed           // shoppers may create accounts (not in the API yet)
config.login.hidePricesForGuests     // guests get price: null, hidden: true
config.login.hideAddToCartForGuests  // a guest's cart write answers login_required
config.customerGroups.enabled
config.customerClub.enabled
config.customerClub.joinMethod       // "registration" | "auto" | "manual" | "auto_and_registration"
```

Show a login form only when `login.enabled` is true. On a shop without customer login, `login()` throws `customer_login_disabled`.

## The API

`shopkit.customers` is a resource on every client:

| Call | What it does |
| - | - |
| `config()` | The shop's settings, above. |
| `login({ email, password })` | Logs in and remembers the session. Resolves with `{ customer, accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt }`. Logging in as a different customer forgets the cart, which belonged to the previous one. |
| `logout()` | Revokes the session on the platform (best effort), then **always** forgets it locally, together with the cart id and the checkout session handle. Never throws. |
| `isLoggedIn` | Synchronous. With async-only storage it lags; see [Async storage](#async-storage). |
| `current` | The profile last returned by `login()` or `me()` in this client, or `null`. |
| `me()` | The profile and the customer's `group` (`{ id, name, priceVatDisplay }`, or `null`). `null` for a guest, without a request. Concurrent calls share one request. |
| `club.get()` | Club membership and points, or `null` for a guest. Throws `customer_club_disabled` on a shop without the club. |
| `onChange(listener)` | Called with `{ customerId, customer }` on login and when `me()` reads a new profile, and with `null` on logout or when the session ends. Returns an unsubscribe function. |
| `setSession({ accessToken, refreshToken?, … })` | Adopt a session minted elsewhere (your server, a native app). Without `refreshToken`, the session ends when the access token expires. |
| `getAccessToken()` | A valid access token, renewed first when needed, or `null` for a guest. For handing to your own backend. |
| `cacheKey` | A short string that changes on every login and logout (not on a new profile). Key your own caches of priced data on it. |

```ts theme={null}
const club = await shopkit.customers.club.get()
club.member                 // true
club.points.balance         // 340
club.reward.remaining       // 160 points to the next reward code
club.earning                // { pointsPerUnit: 1, currency: "SEK" }
```

### The `qb:customer` event

In a browser, every login, logout and expiry is also dispatched on `document` as a `qb:customer` event (the name is exported as `CUSTOMER_EVENT`). Its `detail` is `{ customerId }`, or `null`. It never carries the profile, which other scripts on the page have no business reading.

```js theme={null}
document.addEventListener("qb:customer", (event) => {
  renderHeader(event.detail)   // null = logged out
})
```

## Prices for customers and guests

Catalog responses say who they were priced for. Each variant price has a `source`:

| `price.source` | Meaning |
| - | - |
| `"base"` | The product's own price |
| `"sale"` | A sale price |
| `"customer_group"` | The logged-in customer's group price |
| `"campaign"` | A [campaign storefront's](/kit/concepts/campaign-storefronts) override |

The response as a whole carries `{ audience, groupId, vatDisplay }`. Lists and searches carry it once per page, as `page.meta.pricing`. Single reads (`products.get()`, `getBySlug()`) carry it as `product.pricing`.

```ts theme={null}
const page = await shopkit.products.search({ categoryId })
page.meta?.pricing   // { audience: "customer_group", groupId: 4, vatDisplay: "excl" }
```

* `audience` is `"anonymous"`, `"customer"` or `"customer_group"`.
* `vatDisplay` is how the customer's group shows prices: `"incl"` or `"excl"` VAT, or `null` outside a group. It's a display hint only; the amounts are the same either way.

All of these fields are optional in the types; they're absent from a platform older than them.

### Hidden prices

A shop can show prices to logged-in customers only (`config().login.hidePricesForGuests`). A guest then gets `price: null` with `hidden: true`. Show a "log in to see prices" hint instead of a price:

```tsx theme={null}
{variant.price.hidden ? <a href="/login">Log in to see prices</a> : <Price amount={variant.price.price} />}
```

With `hideAddToCartForGuests`, a guest's cart write is refused with `login_required`. Price filters and price sorting are ignored for a guest who can't see prices.

## Sessions and renewal

A session is a 15-minute access token plus a refresh token that rotates on every use. It ends on logout, when the customer's password changes, when the merchant blocks the customer, after 30 days without a renewal, and in any case 90 days after login. The kit handles renewal:

* **Before a request**, an access token that expires within 30 seconds is renewed first. Expiry is timed from when the token arrived, so a device with a wrong clock behaves the same as any other.
* **`customer_token_expired`** from the platform renews once and retries the request. Concurrent requests share one renewal.
* **Before every renewal the kit re-reads storage.** Another tab, or another client on the same storage, may already have renewed; its pair is adopted, and no request is made when its access token is still fresh. In a browser with Web Locks, renewals are also serialized across tabs. When two renewals do race, the platform accepts the previous refresh token for 30 seconds after a rotation. An older refresh token, or the previous one after those 30 seconds, is treated as stolen and revokes the session.
* **Other tabs.** A login or logout in another tab reaches this one through the `storage` event (`localStorage`), or when the tab becomes visible again (cookies send no event). `onChange` fires as it would for a login here.
* **A session the platform refuses** (`customer_token_invalid`, a token for another shop, or a renewal refused because the customer was blocked or login was switched off) ends. It's forgotten, `onChange(null)` fires, and the cart id goes too, since the cart is bound to the customer. A **GET** is then retried as a guest, so a product grid shows guest prices instead of an error. A cart write or a checkout throws instead, because replaying it as a guest could create a cart the shopper didn't mean to.
* **A renewal that fails any other way** (offline, a 5xx, a 429) keeps the session and throws, so a network blip doesn't log anyone out.

### The cart belongs to the customer

The first cart request made with a customer's token binds the cart to that customer, so a guest's cart carries over on login, repriced for the customer's group. From then on, only that customer can read or check out the cart. A guest, or another customer, gets `customer_token_required` or `cart_customer_mismatch`. The remembered-cart calls (`cart.current()`, `cart.add()`, `CartStore`, `useCart()` and the cart elements) treat such a cart as gone, forget it and start a fresh one. To do the same by hand, call `cart.forgetCurrent()`, which forgets the remembered id without deleting the cart.

## Errors

Customer-account refusals are thrown as `ShopkitAuthError`, a `ShopkitApiError` with a `code`:

| `code` | Status | When |
| - | - | - |
| `invalid_credentials` | 401 | Wrong email or password. It never says which. |
| `customer_blocked` | 403 | The merchant has blocked the customer. |
| `customer_login_disabled` | 404 | The shop has no customer login. |
| `rate_limited` | 429 | Too many login or renewal attempts. Only from a customer-account call; any other 429 is a plain `ShopkitApiError`. |
| `customer_token_expired`, `customer_token_invalid`, `customer_token_shop_mismatch` | 401 / 403 | Handled by the kit as described above. You see them only on a write that couldn't be retried. |
| `customer_token_required` | 401 | A request without a customer token reached something that belongs to a customer, usually a cart. |
| `cart_customer_mismatch` | 403 | The cart belongs to another customer. |
| `login_required` | 403 | The shop hides add-to-cart from guests. |
| `customer_club_disabled` | 404 | The shop has no customer club. |

```ts theme={null}
import { ShopkitAuthError } from "@quickbutik/kit"

try {
  await shopkit.customers.login({ email, password })
} catch (error) {
  if (error instanceof ShopkitAuthError && error.code === "invalid_credentials") {
    showError("Wrong email or password")
  } else if (error instanceof ShopkitAuthError && error.code === "rate_limited") {
    showError("Too many attempts. Try again in a few minutes.")
  } else throw error
}
```

<Warning>
  Repeated failed logins lock an account's headless login for a while (10 failures in 15 minutes). Anyone who knows a shopper's email can trigger this, so word the `rate_limited` message as "try again later", not as a security alert.
</Warning>

## Where the session is stored

The session is kept under `<prefix>_customer_session` in the client's storage adapter, next to the cart id. It's the only credential the kit ever stores, and only while a customer is logged in. See [Storage & SSR](/kit/concepts/storage-and-ssr).

### Server-rendered storefronts: an httpOnly cookie

With server rendering, keep the session in an **httpOnly** cookie written by the server, so no script on the page can read it:

```ts app/api/login/route.ts theme={null}
import { createShopkitClient, createRequestCookieStorage, ShopkitAuthError } from "@quickbutik/kit"

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

export async function POST(request: Request) {
  const { email, password } = await request.json()
  const collect: string[] = []
  const shopkit = createShopkitClient(base).withStorage(
    createRequestCookieStorage(request, {
      collect,
      attributes: { httpOnly: true, secure: true, sameSite: "lax" },
    }),
  )

  try {
    const { customer } = await shopkit.customers.login({ email, password })
    const headers = new Headers({ "content-type": "application/json" })
    for (const cookie of collect) headers.append("set-cookie", cookie)
    return new Response(JSON.stringify({ firstName: customer.firstName }), { headers })
  } catch (error) {
    if (error instanceof ShopkitAuthError) {
      return Response.json({ code: error.code }, { status: error.status })
    }
    throw error
  }
}
```

That client's cookie attributes apply to every cookie it writes, including the cart id, and a browser client never sees an httpOnly session. So in this setup **every cart and checkout call goes through the server**, with a client built like the one above:

* A browser client has no token, so it's refused the customer's cart (`customer_token_required`) and starts a new, guest-priced cart.
* Catalog reads from the browser work, but they're priced for a guest. Fetch customer-priced data on the server and pass it down.
* `useCustomer()` in the browser reports a guest. Render account UI on the server.

### Renew where cookies can be written

The refresh token rotates on every renewal, and the new one has to be written back. A client whose storage can't write never renews:

* a cookie accessor without `set` (a React Server Component),
* `createRequestCookieStorage()` without `collect`,
* any adapter with `writable: false`, or
* a client created with `customerSession: { renew: false }`.

Such a client sends the access token while it's valid, and treats an expired one as a guest for its own requests. It never logs anyone out.

So renew **before** rendering, where cookies can be written, and hand the rotated cookies both to the render and to the browser. In Next.js that's middleware:

```ts middleware.ts theme={null}
import { NextResponse, type NextRequest } from "next/server"
import { createRequestCookieStorage, createShopkitClient, parseCookieHeader } from "@quickbutik/kit"

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

export async function middleware(request: NextRequest) {
  const collect: string[] = []
  const shopkit = createShopkitClient(base).withStorage(
    createRequestCookieStorage(request, {
      collect,
      attributes: { httpOnly: true, secure: true, sameSite: "lax" },
    }),
  )
  await shopkit.customers.getAccessToken() // renews only when needed
  if (collect.length === 0) return NextResponse.next()

  // The render reads the REQUEST's cookies, so the rotated ones go there too;
  // otherwise this render would still see the old pair.
  const headers = new Headers(request.headers)
  headers.set("cookie", applySetCookies(request.headers.get("cookie"), collect))
  const response = NextResponse.next({ request: { headers } })
  for (const cookie of collect) response.headers.append("set-cookie", cookie)
  return response
}

function applySetCookies(header: string | null, setCookies: string[]): string {
  const jar = parseCookieHeader(header)
  for (const cookie of setCookies) {
    const [pair = "", ...attributes] = cookie.split(";")
    const eq = pair.indexOf("=")
    const name = pair.slice(0, eq).trim()
    if (attributes.some((a) => /^\s*max-age=0\s*$/i.test(a))) delete jar[name]
    else jar[name] = decodeURIComponent(pair.slice(eq + 1))
  }
  return Object.entries(jar)
    .map(([name, value]) => `${name}=${encodeURIComponent(value)}`)
    .join("; ")
}
```

Server components then build their client from the request's cookies as usual (read-only, so they never renew) and find a token that's already fresh. Two renewals racing (two tabs, or middleware and a route handler) are harmless within the platform's 30-second grace window.

<Warning>
  A renewal that's **never written back** ends the session. The browser keeps the refresh token that was just replaced, and presenting it after the 30-second window revokes the session. Never renew from a client that can't return its cookies to the browser.
</Warning>

### Browser-only storefronts: the XSS trade-off

Without a server, the session lives where the cart id does: in a cookie written by `document.cookie`, or in `localStorage`. Both are readable by any script on the page, so a cross-site scripting hole on the storefront would expose the session. The damage is bounded: an access token lives 15 minutes, logout revokes the refresh token, and the session only reaches that customer's own data and prices. It's still real, so keep third-party scripts to a minimum, use a [Content Security Policy](/kit/reference/script-tag#content-security-policy), and prefer the server setup for a shop whose customers have meaningful group prices.

### One client per request on the server

A logged-in session belongs to one shopper. On a server, **never** let a long-lived client hold one. Build a client per request with `withStorage()`, as above, so the session comes from that request's cookies and dies with it. The kit keeps no session anywhere but in the client instance and its storage adapter.

### Don't share caches of customer-priced data

A response for a logged-in customer belongs to that customer. Don't put it in a cache shared between requests: no `unstable_cache`, `"use cache"` or `revalidate` window around a customer-priced call in Next.js, no CDN, no module-level map. The platform answers customer reads with `Cache-Control: private, no-store` for the same reason.

React's `cache()`, which memoizes within one server request, is fine. Anything that outlives the request isn't. The kit's own product cache (`getProductPromise()`) is keyed on `customers.cacheKey`, so it never mixes customers. For a slug lookup shared between requests, cache the slug → id map (it's the same for everyone) and fetch the product per request.

### Async storage

With an adapter that only answers asynchronously (Next's `cookies()`), `isLoggedIn` is `false` until the session has been read. Any request reads it, or call `await shopkit.customers.getAccessToken()` to read it explicitly.

## React

```tsx theme={null}
import { useCustomer, useCustomerClub } from "@quickbutik/kit/react"

function LoginForm() {
  const { login, loading, error } = useCustomer()
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        const form = new FormData(e.currentTarget)
        login({ email: String(form.get("email")), password: String(form.get("password")) }).catch(() => {})
      }}
    >
      <input name="email" type="email" autoComplete="username" required />
      <input name="password" type="password" autoComplete="current-password" required />
      {error && <p role="alert">Wrong email or password</p>}
      <button disabled={loading}>Log in</button>
    </form>
  )
}

function Points() {
  const { data: club } = useCustomerClub()   // null for a guest
  return club?.member ? <p>{club.points.balance} points</p> : null
}
```

* `login` rejects with the `ShopkitAuthError` and also puts it on `error`. `logout` never rejects.
* `useCustomer()` reads the profile once when it finds a session without one (a reload, or after `setSession()`).
* On every login and logout, the catalog hooks refetch and `useCart()` re-reads the cart, so prices and the basket follow the customer with no wiring.
* `useClientCustomerKey(client)` is the bare subscription to `customers.cacheKey`, for your own data hooks.

See [React hooks](/kit/reference/react#customer-accounts).

## Web components

There's no login element. Call the ambient client's `customers` from your own form, and the elements follow: every `<qb-product>`, `<qb-product-list>` and cart element re-reads on login and logout, as it does on a currency switch.

```html theme={null}
<form id="login">
  <input name="email" type="email" autocomplete="username" required>
  <input name="password" type="password" autocomplete="current-password" required>
  <button>Log in</button>
  <p id="login-error" hidden>Wrong email or password</p>
</form>

<script type="module">
  document.getElementById("login").addEventListener("submit", async (event) => {
    event.preventDefault()
    const form = new FormData(event.currentTarget)
    try {
      await Quickbutik.client.customers.login({ email: form.get("email"), password: form.get("password") })
    } catch (error) {
      if (error instanceof Quickbutik.ShopkitAuthError) document.getElementById("login-error").hidden = false
      else throw error
    }
  })

  document.addEventListener("qb:customer", (event) => {
    document.getElementById("login").hidden = event.detail !== null
  })
</script>
```

## Troubleshooting

| Symptom | Cause | Fix |
| - | - | - |
| `403` on `login()` with `required_scopes` | The key doesn't carry `customer:session` | Use the key from the admin's Custom storefront view |
| `customer_login_disabled` | The shop has no customer login | Switch on the customer-login app and login in the Quickbutik admin |
| Logged in, but prices are still the guest's | The customer is in no group, or the request was made by another client (a browser client in an httpOnly-cookie setup) | Check `customers.me()` → `group`, and `page.meta.pricing.audience`. Fetch customer-priced data on the server |
| Logged out after about 15 minutes in a server-rendered shop | Renewals happen where cookies can't be written back, or never happen | Renew in middleware and return the rotated cookies, as in [Renew where cookies can be written](#renew-where-cookies-can-be-written) |
| The cart empties on logout, or when another customer logs in | Expected: a cart belongs to one customer | Nothing to fix. A guest's cart is kept on login, repriced and bound to the customer |
| One customer sees another customer's prices | A customer-priced response was cached across requests | See [Don't share caches](#dont-share-caches-of-customer-priced-data) |


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