TanStack i18n
Guides

Adapters

Configure locale persistence and inference adapters. Learn how to use cookies, server functions, and Accept-Language headers in TanStack i18n.

This guide explains how locale is stored and inferred on each request. You will configure cookie persistence, first-visit inference, optional session-backed locale for logged-in users, and adapter ordering — still on the same en / ar app.

Prerequisites

Persist vs infer

Two chains, two jobs:

ChainWhen it runsWritten on user switch
persist[]Every getLocale(); server entry cookie syncYes
infer[]First-visit server entry onlyNo

Persist = user choice (cookie, database). Infer = machine guess (Accept-Language).

Infer does not run in getLocale(). If it did, browser headers would override cookie after a manual language switch. Guarantees: Behavior contract.

Most marketing visitors are anonymous. When you omit adapters, persist defaults to [cookie()] — same as calling cookie() with no arguments:

import { cookie } from "@Wadiou/tanstack-i18n/adapters";

cookie()

Default cookie name is LOCALE (DEFAULT_COOKIE_NAME). Override only when needed:

cookie({ name: "MY_LOCALE" })

Read: server uses Cookie header; client uses document.cookie. Returns null if missing or not in locales.

Write: client uses Cookie Store API or document.cookie. Server does not call write directly — redirect/sync-cookie responses use serialize(locale) for Set-Cookie.

Tighten attributes for your domain:

cookie({
  path: "/",
  maxAge: 31536000,
  sameSite: "lax",
}),

Adapter id: cookie:LOCALE. Only one cookie() allowed in persist[].

localStorage adapter — client-only / SPA persistence

For Single Page Applications (SPAs) that do not use SSR, or where you want to store the selected language completely client-side in the browser:

import { localStorage } from "@Wadiou/tanstack-i18n/adapters";

localStorage()

Default key is LOCALE (DEFAULT_LOCAL_STORAGE_KEY). Override if needed:

localStorage({ key: "MY_LOCALE" })
  • Read: Reads from window.localStorage. If a stored value exists and matches one of the supported locales, it returns it; otherwise, returns null.
  • Write: Writes the active locale to window.localStorage.

Because localStorage is not sent with HTTP requests, it is ideal for pure client-side SPA setups (e.g. using TanStack React Router without TanStack Start). If you ever need to support SSR or server fallback in the future, you can also combine localStorage and cookie (the adapter is built to safely return null and no-op on the server without crashing):

adapters: {
  persist: [
    localStorage({ key: "LOCALE" }),
    cookie({ name: "LOCALE" }),
  ],
}

In this setup, localStorage is checked first on the client. If it returns null (e.g., during server-side rendering or on first load), the runtime falls back to the cookie adapter.

Accept-Language — first visit to /

A visitor lands on / with no cookie. The server entry runs infer:

infer: [acceptLanguage()],

The adapter parses Accept-Language, picks the best match from locales, or returns null. Combined with firstVisit.mode: "redirect", they may redirect to /ar/ before any cookie exists.

This does not run on subsequent getLocale() calls — only the server entry first-visit path (TanStack Start).

serverFn — logged-in account locale

When the marketing site adds accounts, store locale in session/DB:

import { serverFn } from "@Wadiou/tanstack-i18n/adapters";

serverFn({
  read: async (ctx) => {
    if (!ctx.request) return null;
    return getLocaleFromSession(ctx.request);
  },
  write: async (locale, ctx) => {
    await saveLocaleToSession(locale, ctx.request);
  },
}),

PersistRunContext supplies runtime, pathname, locales, defaultLocale, request, cookieHeader.

Client read always returns null — client resolution uses cookie or URL. Server reads power SSR and the server entry.

Read: first adapter returning non-null wins.

Write: changeLocale() writes all persist adapters.

adapters: {
  persist: [
    serverFn({ read: fromDb, write: toDb }),
    cookie({ name: "LOCALE" }),
  ],
  infer: [acceptLanguage()],
},

Logged-in users resolve from DB; anonymous users fall through to cookie. Duplicate adapter ids fail at bind time.

How it works

On first unprefixed GET, the server entry may read infer, then persist, then redirect or set cookies. On every later request, getLocale() uses URL segment → persist chain → defaultLocale — infer is out of that path.

Path-scoped adapter overrides (empty infer on /admin, etc.): Configuration — overrides.

Complete example (so far)

Marketing site with anonymous cookie + first-visit infer:

adapters: {
  infer: [acceptLanguage()],
},

Persist defaults to [cookie()]. With accounts:

adapters: {
  persist: [
    serverFn({ read: fromSession, write: toSession }),
    cookie({ name: "LOCALE" }),
  ],
  infer: [acceptLanguage()],
},

API reference

Factory summary

FactoryKindRole
cookie(options)PersistHTTP cookie
localStorage(options)Persistbrowser localStorage
serverFn({ read, write })PersistYour storage callbacks
acceptLanguage()InferHeader parse on unprefixed GET

LocalStorageOptions

OptionDefault
key"LOCALE"

CookieOptions

OptionDefault
name"LOCALE"
path"/"
maxAge31536000
sameSite"lax"

serverFn callbacks

CallbackSignature
read(ctx) => Locale | null | Promise<…>
write(locale, ctx) => void | Promise<void>

Typical stacks

Scenariopersistinfer
Marketing site (anonymous)[cookie(...)][acceptLanguage()] optional
Logged-in + anonymous[serverFn, cookie]optional
prefix: "never"requiredoptional
Skip infer on APIbase + override empty infer on /api

What's next

Wrap the Start server handler so infer and persist participate in redirects: TanStack Start.

Edit on GitHub