storage
Every app remembers a few things between visits: a collapsed sidebar, the last orders someone searched for, the language they picked. @kitr/storage lets you declare each of those values once, with a type and a default, and then read and write it anywhere without touching localStorage or document.cookie yourself. Bad, old or missing values fall back to the default, other tabs stay in sync, and server rendering never breaks.
It is built on Web Storage and HTTP cookies, and adds types, schema validation, versions with migrations, expiry, hydration-safe React hooks, and a native store on desktop and mobile. Storage items run in the browser; cookies run on both sides. For a value every request on the server shares, use cache.
Don't use it for login tokens or other secrets: anything in Web Storage is readable by every script on the page. Use Secure storage for those.
This guide follows Acme's order dashboard at app.acme.com: Ada collapses the sidebar, searches for orders, switches the language to German, and the server shows a maintenance banner.
When to use it
Use storage for small values the user's device owns: preferences, recent searches, a dismissed notice, a draft. They survive a reload, are read synchronously, and are not shared with other devices.
Use State for values that only live while the page is open, and Query for data the server owns, such as the orders themselves. Copying server data into storage means you now own keeping it fresh. To keep loaded server data on the device for offline use, use Offline, which persists the query cache for you.
Install
kitr add storageFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
What kitr add does:
- Installs
@kitr/storage. - Adds
() => import('@kitr/storage/provider')toprovidersinkitrrc.ts. - Adds
@kitr/storagetopackagesinkitrrc.ts. - Writes
config/storage.ts(shown under Configure). - Writes
app/storage.tswith three sample values:sidebar,recentSearchesandlocaleCookie. In a server-only project it writes onlylocaleCookie. - Adds a
## kitr: storagesection toAGENTS.md: declare every persisted value inapp/storage.ts, never readlocalStorageordocument.cookiedirectly, useuseStorageanduseCookiein components andcookies(event)on the server, and addschema,versionwithmigrate, orttlwhen needed. A server-only project gets one line aboutcookies(ctx.event)instead.
kitr add storage asks no questions and adds no environment variables or scripts. It does not overwrite config/storage.ts or app/storage.ts when they exist.
The app, fullstack, saas, site, realtime, desktop and mobile kits include it. The Desktop, Tauri and Mobile platforms require it, so adding any of them adds storage too.
The generated app/storage.ts in a project with React:
// app/storage.ts
import { defineCookie, defineStorage } from '@kitr/storage'
/**
* Every persisted value, in one file, typed. Components read them with
* `useStorage(item)` / `useCookie(item)` from @kitr/storage/react; plain code with `item.get()`.
*/
export const sidebar = defineStorage('sidebar', { default: { collapsed: false } })
export const recentSearches = defineStorage<string[]>('recent-searches', {
default: [],
area: 'session',
version: 1,
})
/** Readable on the server too (`localeCookie.get(request.headers)`), so SSR picks the right locale. */
export const localeCookie = defineCookie('locale', { default: 'en', maxAge: 60 * 60 * 24 * 365 })
kitr remove storage reverses every step above and deletes config/storage.ts. It leaves app/storage.ts, because you have probably edited it, and lists the files that still import @kitr/storage so you can remove them by hand.
Configure
kitr add storage writes this file. The prefix comes from the name in your package.json, without its scope; for a project named acme it is:
// config/storage.ts
import { defineConfig } from '@kitr/storage'
export default defineConfig({
// Prefixes every storage key and cookie name so several apps on one origin never collide.
prefix: 'acme:',
cookie: { path: '/', sameSite: 'lax', secure: import.meta.env?.PROD ?? false },
})
| Option | Default | Description |
|---|---|---|
prefix |
'' |
Added in front of every storage key and cookie name. sidebar is stored as acme:sidebar. |
cookie |
{} |
Attributes merged into every cookie this app writes. An item's own options and the options passed to set() win over these. |
cookie.path |
'/' |
The URL path a cookie is sent for. |
cookie.domain |
none | The domain a cookie is sent to. .acme.com shares it with every subdomain. |
cookie.maxAge |
none | Lifetime in seconds. Without maxAge or expires, a cookie lasts until the browser closes. |
cookie.expires |
none | An absolute expiry Date. |
cookie.secure |
false |
Send only over HTTPS. Always on when sameSite is 'none'. |
cookie.httpOnly |
false |
Hide the cookie from browser scripts. Only cookies set by the server can be httpOnly. |
cookie.sameSite |
'lax' |
'lax', 'strict' or 'none'. |
@kitr/storage adds no environment variables. import.meta.env?.PROD is Vite's production flag, so cookies are Secure in production builds and work over plain http://localhost in development.
The provider applies this file in register(): it sets the prefix and the cookie defaults. It switches storage items to the platform's native store when a platform registered one, and checks again in boot() for a platform provider listed after it. Items read the prefix every time they touch storage, so values defined before the provider ran still pick it up.
Changing prefix in a live app makes every saved value look missing, because the keys change. Users see the defaults again; nothing is migrated.
Save your first value
Read and write a value from a component with useStorage. It returns the value, a setter and a remover, like useState:
// app/components/sidebar-toggle.tsx
import { useStorage } from '@kitr/storage/react'
import { sidebar } from '#app/storage'
export function SidebarToggle() {
const [state, setState] = useStorage(sidebar)
return (
<button type="button" onClick={() => setState((s) => ({ collapsed: !s.collapsed }))}>
{state.collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
</button>
)
}
Put <SidebarToggle /> in your layout and click it. The label changes to "Expand sidebar". Reload the page: it still says "Expand sidebar". In the browser's devtools, under Application → Local Storage, you see one key:
acme:sidebar {"v":1,"d":{"collapsed":true}}
Every storage item is saved as a small envelope: v is the item's version, d is the data, and e is the expiry time when the item has a ttl. You never read the envelope yourself; get() unwraps it.
The setter takes a value or a function of the current value. The third element, remove, deletes the key so the item reads its default again.
Read and write outside React
Every item has the same three methods outside components, so services, route loaders and event handlers use the same declaration:
// app/lib/orders/search.ts
import { recentSearches } from '#app/storage'
export function rememberSearch(term: string) {
recentSearches.set((current) => [term, ...current.filter((t) => t !== term)].slice(0, 5))
}
rememberSearch('INV-1042')
rememberSearch('Grace')
recentSearches.get() // => ['Grace', 'INV-1042']
recentSearches.remove()
recentSearches.get() // => []
get() is synchronous and returns the same object for the same stored string, so you can call it in a render or a loop without parsing JSON each time. set() notifies every subscriber in this tab at once and the other tabs through the browser's storage event.
To react to changes without React, subscribe. The listener receives the new value and the call returns an unsubscribe function:
// app/lib/layout/body-class.ts
import { sidebar } from '#app/storage'
const stop = sidebar.subscribe((value) => {
document.body.classList.toggle('sidebar-collapsed', value.collapsed)
})
// later, when the layout unmounts
stop()
Choose local or session storage
area picks where the value lives. local, the default, keeps a value until the user or your code clears it. session keeps it for the current tab only: closing the tab forgets it, and a second tab starts empty.
// app/storage.ts
import { defineStorage } from '@kitr/storage'
// Survives restarts: a preference.
export const ordersPageSize = defineStorage('orders-page-size', { default: 25 })
// Forgotten when the tab closes: the search history of this session.
export const recentSearches = defineStorage<string[]>('recent-searches', {
default: [],
area: 'session',
version: 1,
})
ordersPageSize.area is 'local' and recentSearches.area is 'session'. On desktop and mobile, session items live in memory and are gone when the app restarts, which matches what a tab does in the browser.
Validate stored values with a schema
Anything in storage can be edited by the user, an old version of your app or a browser extension. Pass a Standard Schema such as zod or valibot and every read is checked. A value that fails the schema reads as the default:
// app/storage.ts
import { defineStorage } from '@kitr/storage'
import { z } from 'zod'
const orderFilters = z.object({
status: z.enum(['open', 'paid', 'shipped']).nullable(),
customer: z.string().max(80),
})
export const savedFilters = defineStorage('order-filters', {
default: { status: null, customer: '' } as z.infer<typeof orderFilters>,
schema: orderFilters,
})
// What happens with a tampered value
localStorage.setItem('acme:order-filters', '{"v":1,"d":{"status":"lost","customer":"Ada"}}')
savedFilters.get() // => { status: null, customer: '' }
The schema runs on reads, not on writes, so TypeScript guards what you write and the schema guards what comes back. The schema must be synchronous. An async schema, such as zod with an async refine, makes every read return the default.
Change a value's shape with versions
When you change the shape of a stored value, old browsers still hold the old shape. Bump version and give migrate a function that converts what it finds. It receives the old data and the version it was saved under:
// app/storage.ts
import { defineStorage } from '@kitr/storage'
// Version 1 stored { collapsed: boolean }. Version 2 also remembers the width.
export const sidebar = defineStorage('sidebar', {
default: { collapsed: false, width: 280 },
version: 2,
migrate: (old, fromVersion) => {
if (fromVersion === 1) return { ...(old as { collapsed: boolean }), width: 280 }
return { collapsed: false, width: 280 }
},
})
// Ada's browser still holds a version 1 value
localStorage.getItem('acme:sidebar') // => '{"v":1,"d":{"collapsed":true}}'
sidebar.get() // => { collapsed: true, width: 280 }
The migrated value is returned but not written back until the next set(). If migrate throws, or there is no migrate, a value with another version reads as the default. When both migrate and schema are set, the schema checks the migrated value.
Don't change the shape of default without bumping version. The old values would still be read as the new type, and code that reads sidebar.get().width would get undefined.
Expire a value after a time
ttl keeps a value for that many milliseconds after each set(), then treats it as missing. Use it for things that go stale, such as a dismissed notice that should come back next week:
// app/storage.ts
import { defineStorage } from '@kitr/storage'
const sevenDays = 7 * 24 * 60 * 60 * 1000
export const invoiceTipDismissed = defineStorage('invoice-tip-dismissed', {
default: false,
ttl: sevenDays,
})
invoiceTipDismissed.set(true)
localStorage.getItem('acme:invoice-tip-dismissed')
// => '{"v":1,"d":true,"e":1731542400000}'
invoiceTipDismissed.get() // => true, until seven days from now; then false
The expiry e is stored beside the value, so it holds across reloads. Every set() starts a new period. An expired value stays in storage until the next set() or remove(); it is never returned.
Keep tabs in sync
When Ada collapses the sidebar in one tab, every other tab of app.acme.com updates too. useStorage subscribes to the browser's storage event, which fires in the other tabs on every write to localStorage. You don't add anything for this; it works for every local item.
// app/components/sidebar.tsx
import { useStorage } from '@kitr/storage/react'
import type { ReactNode } from 'react'
import { sidebar } from '#app/storage'
export function Sidebar({ children }: { children: ReactNode }) {
const [{ collapsed }] = useStorage(sidebar)
return <aside className={collapsed ? 'w-14' : 'w-72'}>{children}</aside>
}
session items don't sync, because each tab has its own session storage. On desktop and mobile the native drivers have no other tabs to hear from, so changes apply in the one window.
Render on the server without a hydration mismatch
On the server there is no localStorage. get() returns the default and set() does nothing, so the same module imports safely on both sides. useStorage renders the default on the server and on the first browser render, then switches to the stored value right after hydration:
// app/routes.ts
r.page('/orders', { lazy: () => import('./pages/orders') })
// app/pages/orders.tsx
import { useStorage } from '@kitr/storage/react'
import { ordersPageSize } from '#app/storage'
export default function OrdersPage() {
const [pageSize, setPageSize] = useStorage(ordersPageSize)
return (
<select value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}>
<option value={25}>25 per page</option>
<option value={50}>50 per page</option>
<option value={100}>100 per page</option>
</select>
)
}
If Ada saved 50, the server sends HTML with 25 selected, the browser hydrates with 25, and then shows 50. The markup always matches, so React never throws a hydration error.
Don't read ordersPageSize.get() directly in a server-rendered component: the server returns 25 and the browser returns 50, and React reports a mismatch. Use the hook. If the first HTML must already show the saved value, store it in a cookie instead, as the next sections show.
Handle missing, invalid and blocked storage
Storage items never throw on a read. Each case below returns the default:
| Situation | get() returns |
set() does |
|---|---|---|
| Nothing saved yet | the default | saves the value |
| Saved value isn't valid JSON | the default | overwrites it |
Saved value fails schema |
the default | saves the value |
Saved under another version, no migrate |
the default | saves with the new version |
Older than its ttl |
the default | saves with a new expiry |
| On the server | the default | nothing |
| Storage blocked (some private modes, sandboxed iframes) | the default | nothing |
| Storage full | see below | doesn't throw |
When the browser refuses a write because storage is full, set() catches the error and still notifies subscribers. If the key had no saved value before, get() returns the new value for the rest of the page's life. If it had one, get() keeps returning the last value that was saved.
Declare a cookie
A cookie is the one value both the browser and the server can read, so it is the right home for anything the server needs to render the first page, such as Ada's language. Declare it with defineCookie, next to the storage items:
// app/storage.ts
import { defineCookie } from '@kitr/storage'
export type Locale = 'en' | 'de' | 'fr'
export const localeCookie = defineCookie<Locale>('locale', {
default: 'en',
maxAge: 60 * 60 * 24 * 365,
})
export const tablePrefs = defineCookie('table-prefs', {
default: { dense: false, columns: ['id', 'customer', 'total'] },
maxAge: 60 * 60 * 24 * 30,
})
A cookie with a string default is stored as plain text: acme:locale=de. Any other default is stored as URL-encoded JSON: acme:table-prefs=%7B%22dense%22%3Atrue%2C.... Pass codec to choose yourself. schema works the same as on storage items.
Every CookieOptions field can be set on the item: path, domain, maxAge, expires, secure, httpOnly and sameSite. The item's options win over cookie in config/storage.ts.
Keep cookies small. The browser sends every cookie with every request to app.acme.com, and most browsers cap one cookie at about 4 KB. Large or private values belong in storage items or on the server.
Read and write cookies in a component
In a component, useCookie works like useStorage:
// app/components/locale-switch.tsx
import { useCookie } from '@kitr/storage/react'
import { type Locale, localeCookie } from '#app/storage'
export function LocaleSwitch() {
const [locale, setLocale] = useCookie(localeCookie)
return (
<select value={locale} onChange={(e) => setLocale(e.target.value as Locale)}>
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
</select>
)
}
Ada picks Deutsch. The browser now holds:
acme:locale=de; Max-Age=31536000; Path=/; SameSite=Lax
The next request to the server carries Cookie: acme:locale=de. Outside React, call the same methods on the item:
// app/lib/settings/locale.ts
import { localeCookie } from '#app/storage'
localeCookie.set('fr')
localeCookie.get() // => 'fr'
localeCookie.remove()
localeCookie.get() // => 'en'
In the browser, get() with no argument reads document.cookie. set() always writes a script-readable cookie, even if the item says httpOnly: true, because the browser refuses to let scripts write httpOnly cookies. Cookies don't fire events across tabs; another tab sees the new value on its next read or render.
If other code writes document.cookie, for example a consent banner from a vendor, call notifyCookiesChanged() so mounted useCookie hooks re-read:
// app/lib/vendor-banner.ts
import { notifyCookiesChanged } from '@kitr/storage'
window.addEventListener('vendor:consent-saved', () => notifyCookiesChanged())
Render cookies during server rendering
During server rendering there is no document.cookie, so useCookie needs the request's Cookie header. Wrap the tree once in <CookieHeader> in the root route. In a TanStack Start app, read the header in a server function and pass it down from the loader:
// app/routes.ts
import { head, readCookieHeader, RootDocument } from './document'
r.root({ head, loader: () => readCookieHeader(), component: RootDocument })
// app/document.tsx
import { createServerFn } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
import { getRouteApi, HeadContent, Outlet, Scripts } from '@kitr/router'
import { CookieHeader } from '@kitr/storage/react'
export const readCookieHeader = createServerFn({ method: 'GET' }).handler(() =>
getRequest().headers.get('cookie'),
)
const root = getRouteApi('__root__')
export function RootDocument() {
const cookieHeader = root.useLoaderData()
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<CookieHeader value={cookieHeader}>
<Outlet />
</CookieHeader>
<Scripts />
</body>
</html>
)
}
With the wrapper, <LocaleSwitch /> renders Deutsch in the first HTML for Ada, and the browser hydrates with the same value. Without it, the server renders the default (English) and the browser corrects it after hydration, which shows as a flash.
When you only need one cookie's value in a loader, skip the wrapper and read it from the headers directly. get() accepts a Headers object, a raw Cookie header string, or anything with a cookie field:
// app/pages/orders.tsx
import { createServerFn } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
import { localeCookie } from '#app/storage'
export const readLocale = createServerFn({ method: 'GET' }).handler(() =>
localeCookie.get(getRequest().headers),
)
localeCookie.get('acme:locale=de; acme:table-prefs=%7B%7D') // => 'de'
localeCookie.get(null) // => 'en'
Read and set cookies on the server
On the server, cookies() from @kitr/storage/server gives typed access to one request's cookies. Pass it the h3 event in a controller or middleware. Reads come from the request's Cookie header; writes append a Set-Cookie header to the response:
// server/controllers/settings-controller.ts
import type { HttpContext } from '@kitr/api'
import { cookies } from '@kitr/storage/server'
import { type Locale, localeCookie } from '#app/storage'
export default class SettingsController {
async show({ event }: HttpContext) {
const jar = cookies(event)
return { locale: jar.get(localeCookie) }
}
async update({ event, body }: HttpContext<{}, {}, { locale: Locale }>) {
const jar = cookies(event)
jar.set(localeCookie, body.locale)
return { locale: body.locale }
}
}
PUT /api/settings with { "locale": "de" } answers with this header:
Set-Cookie: acme:locale=de; Max-Age=31536000; Path=/; SameSite=Lax
jar.remove(item) appends a header that expires the cookie (Max-Age=0 and an Expires date in 1970). jar.header is the raw Cookie header, which you can pass to <CookieHeader>.
Where you only have Fetch primitives, such as a Cloudflare Worker or a plain fetch handler, pass the Request and the response Headers:
// server/edge/locale.ts
import { cookies } from '@kitr/storage/server'
import { localeCookie } from '#app/storage'
export function handleLocale(request: Request): Response {
const headers = new Headers({ 'content-type': 'application/json' })
const jar = cookies(request, headers)
const locale = jar.get(localeCookie)
if (locale === 'en') jar.set(localeCookie, 'de')
return new Response(JSON.stringify({ locale }), { headers })
}
Set an httpOnly cookie from the server
A cookie the browser's scripts must never read, such as an opaque session id, is httpOnly and is set only by the server. Declare it with httpOnly: true; the server write honours it:
// server/auth/session-cookie.ts
import { defineCookie } from '@kitr/storage'
export const sessionCookie = defineCookie('sid', {
default: '',
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 8,
})
// server/controllers/sessions-controller.ts
import type { HttpContext } from '@kitr/api'
import { cookies } from '@kitr/storage/server'
import { sessionCookie } from '#server/auth/session-cookie'
export default class SessionsController {
async store({ event }: HttpContext) {
const sessionId = crypto.randomUUID()
cookies(event).set(sessionCookie, sessionId)
return { ok: true }
}
async destroy({ event }: HttpContext) {
cookies(event).remove(sessionCookie)
return { ok: true }
}
}
Set-Cookie: acme:sid=3f2b...; Max-Age=28800; Path=/; Secure; HttpOnly; SameSite=Strict
Options passed to set() win over the item's, so jar.set(sessionCookie, id, { maxAge: 60 * 60 * 24 * 30 }) writes a "remember me" cookie from the same declaration.
Warning A cookie value is not signed or encrypted. Anyone can edit their own cookies, so never trust a cookie as proof of who someone is: store a random id and look it up on the server. For sign-in, use Auth.
Share cookies across subdomains
To read one cookie on acme.com, app.acme.com and billing.acme.com, set domain to the parent domain. Set it once in config so every cookie gets it, or on one item:
// config/storage.ts
import { defineConfig } from '@kitr/storage'
export default defineConfig({
prefix: 'acme:',
cookie: {
path: '/',
domain: '.acme.com',
sameSite: 'lax',
secure: import.meta.env?.PROD ?? false,
},
})
Every Set-Cookie now ends with Domain=.acme.com; Path=/; .... A cookie set with one domain can only be removed with the same domain, so keep it in config rather than passing it to some set() calls and not others.
Build and parse cookie headers by hand
parseCookies and serializeCookie are the two functions every cookie item uses. Use them when you need a cookie that isn't declared, such as one a third party set:
// server/lib/referrer.ts
import { parseCookies, serializeCookie } from '@kitr/storage'
parseCookies('acme:locale=de; utm_source=newsletter; broken')
// => { 'acme:locale': 'de', utm_source: 'newsletter' }
serializeCookie('utm_source', 'newsletter', { maxAge: 3600 })
// => 'utm_source=newsletter; Max-Age=3600; Path=/; SameSite=Lax'
serializeCookie('utm_source', null)
// => 'utm_source=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax'
parseCookies skips parts without =, trims spaces and URL-decodes values; a value that fails to decode is kept as it is. serializeCookie URL-encodes the value and adds Path=/ and SameSite=Lax unless you pass others, plus the cookie defaults from config. It does not add the config prefix; only items do.
An item can do the same for its own value: localeCookie.serialize('de') returns the full Set-Cookie value with the prefix, and localeCookie.decode('de') turns one raw value into the typed value or the default.
Store values natively on desktop and mobile
Storage items use the platform's own store when a platform registers a driver, and Web Storage otherwise. You declare and read items the same way everywhere; only where the bytes go changes. The storage provider hydrates the native store before the first render, so get() stays synchronous and already holds the saved values.
| Where the app runs | Driver | Where local items are saved |
|---|---|---|
| Browser | web:local |
localStorage |
| Desktop (Electron) | electron:ipc |
kitr-storage.json in Electron's userData folder |
| Tauri | tauri:store |
kitr.json in the app data folder |
| Mobile, Capacitor | capacitor:preferences |
Capacitor Preferences |
| Server, or storage blocked | none | nothing; items read their defaults |
The storage provider looks for a registered driver in its own register() step and again in boot(), after every provider has registered. So the platform's provider (@kitr/desktop/provider, @kitr/tauri/provider or @kitr/mobile/provider) can be listed before or after @kitr/storage/provider in kitrrc.ts.
On native platforms session items use an in-memory driver. Writes to a native store happen in the background after the in-memory copy updates; a failed write is logged as [@kitr/storage] <driver>: <error> and the in-memory value stays.
Browser
There is nothing to set up. Without a platform driver, local items use localStorage and session items use sessionStorage. Read what's stored in the browser devtools under Application → Local Storage. See MDN on Web Storage for size limits, which are about 5 MB per origin.
Desktop with Electron
Desktop with the Electron shell registers electronStorageDriver(), which sends every write to the main process over the kitr:storage:load, kitr:storage:set and kitr:storage:remove IPC channels. The main process must serve them, which the desktop scaffold's main.ts does with one call:
// platforms/app/main.ts
import { app, ipcMain } from 'electron'
import { handleKitrIpc } from '@kitr/desktop/main'
app.whenReady().then(() => {
handleKitrIpc(ipcMain, app)
// ... create the window
})
Values land in kitr-storage.json in app.getPath('userData'). Pass { storageFile } as the third argument to choose another file. Electron is the recommended desktop shell for most apps, because it ships its own browser engine and behaves the same on every OS.
Desktop with Tauri
Inside a Tauri webview, the Tauri provider registers tauriStoreDriver() on the store plugin, which writes kitr.json in the app data folder. kitr add tauri installs @tauri-apps/plugin-store, and its scaffold adds the Rust plugin and the store:default permission; nothing changes in your items. Tauri is the alternative to Electron: it uses the operating system's webview, so the app is smaller but renders differently per OS.
Mobile with Capacitor
Mobile registers preferencesDriver() on Capacitor Preferences. iOS and Android may clear a WebView's localStorage when the device is low on space; Preferences is kept in the app's own data, so saved values survive.
Testing
Storage items are module-level values, so a test reads and writes them directly. In Vitest's default Node environment there is no localStorage; give the items an in-memory driver with setStorageDriver(createMemoryStorageDriver()) and they behave as they do in the browser. Cookies need no setup: pass a Request and a Headers object to cookies().
// app/storage.test.ts
import { createTestApp } from '@kitr/testing'
import {
createMemoryStorageDriver,
getStorageDriver,
setStorageDriver,
setStoragePrefix,
} from '@kitr/storage'
import storageProvider from '@kitr/storage/provider'
import { cookies } from '@kitr/storage/server'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { localeCookie, recentSearches, sidebar } from '#app/storage'
beforeEach(() => {
setStorageDriver(createMemoryStorageDriver())
})
afterEach(() => {
setStoragePrefix('')
})
describe('app/storage', () => {
it('remembers the sidebar under the configured prefix', async () => {
await createTestApp({
providers: [storageProvider],
config: { storage: { prefix: 'acme:' } },
})
expect(sidebar.get()).toEqual({ collapsed: false })
sidebar.set((s) => ({ collapsed: !s.collapsed }))
expect(sidebar.get()).toEqual({ collapsed: true })
expect(getStorageDriver('local')?.get('acme:sidebar')).toBe('{"v":1,"d":{"collapsed":true}}')
})
it('keeps recent searches in the session area', () => {
recentSearches.set(['INV-1042'])
expect(recentSearches.get()).toEqual(['INV-1042'])
expect(getStorageDriver('session')?.keys()).toEqual(['recent-searches'])
})
it('reads and sets the locale cookie on the server', () => {
const request = new Request('https://app.acme.com/orders', {
headers: { cookie: 'locale=de' },
})
const response = new Headers()
const jar = cookies(request, response)
expect(jar.get(localeCookie)).toBe('de')
jar.set(localeCookie, 'fr')
expect(response.get('set-cookie')).toBe('locale=fr; Max-Age=31536000; Path=/; SameSite=Lax')
})
})
The prefix and cookie defaults are module state too. Reset them in afterEach with setStoragePrefix('') and setCookieDefaults({}) when a test sets them through config, or later tests see acme: keys.
To test components and the cross-tab behaviour, run the file in a DOM environment with // @vitest-environment happy-dom on its first line. Items then use the real localStorage and sessionStorage; clear them between tests and render with Testing Library:
// app/components/sidebar-toggle.test.tsx
// @vitest-environment happy-dom
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it } from 'vitest'
import { SidebarToggle } from '#app/components/sidebar-toggle'
afterEach(() => localStorage.clear())
it('collapses and remembers it', () => {
render(<SidebarToggle />)
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeTruthy()
expect(localStorage.getItem('sidebar')).toBe('{"v":1,"d":{"collapsed":true}}')
})
To check what the server renders before hydration, call renderToString from react-dom/server: a useStorage component renders the default, and a useCookie component inside <CookieHeader value="locale=de"> renders de.
Extend
One interface plugs into storage: StorageDriver, the synchronous store behind defineStorage items on a platform. Its full type is in the API reference. For a value every request on the server shares, use cache.
Write a storage driver
| Member | Description |
|---|---|
name |
Shown in the devtools log. |
hydrate() |
Optional. Load every key into memory before the first render. The provider awaits it in boot(). |
get(key) |
The raw string, or null. Must answer synchronously. |
set(key, raw) |
Store a raw string. |
remove(key) |
Delete one key. |
keys() |
Every stored key, for the devtools panel. |
subscribe(listener) |
Optional. Call listener(key) when something outside this window changed a key (null for all keys); return an unsubscribe function. |
Most native stores are async. createAsyncStorageDriver wraps one: load fills an in-memory cache at boot, and write and erase run in the background after each change, so reads stay synchronous. This driver keeps values in IndexedDB through the small idb-keyval library:
// app/providers/idb-storage.ts
import { defineProvider } from 'kitr/app'
import { createAsyncStorageDriver } from '@kitr/storage'
import { createStore, del, entries, set } from 'idb-keyval'
const store = createStore('acme', 'storage')
export const idbStorageDriver = createAsyncStorageDriver({
name: 'idb:acme',
load: async () => {
const out: Record<string, string> = {}
for (const [key, value] of await entries<string, string>(store)) out[key] = value
return out
},
write: (key, raw) => set(key, raw, store),
erase: (key) => del(key, store),
onError: (error) => console.warn('[acme] could not save to IndexedDB', error),
})
export default defineProvider({
name: 'idb-storage',
register(app) {
app.drivers.register('storage', idbStorageDriver, { platform: 'web' })
},
})
Register it in kitrrc.ts. The storage provider resolves the driver again in boot(), so its place in the list does not matter:
// kitrrc.ts
import { defineKitrConfig } from 'kitr/app'
export default defineKitrConfig({
providers: [
() => import('#app/providers/idb-storage'),
() => import('@kitr/storage/provider'),
// ...
],
// ...
})
The driver with the highest priority wins when several are registered; platform drivers default to 10. session items keep an in-memory driver. To replace both areas directly, for example in a test or a script, call setStorageDriver(driver, { session: otherDriver }).
Devtools
With Devtools installed, the provider adds a Storage panel in development. It lists every key in local storage, session storage and document.cookie, with the stored value pretty-printed and a remove button per row. The badge counts local and session keys. Two actions, Clear localStorage and Clear sessionStorage, remove every key in that area, including keys other code wrote.
At boot the provider logs driver: web:local (or the native driver's name, or none (server)) to the Log panel under storage, and logs <key> changed outside this context when a driver reports a change from elsewhere. Write your own lines to the same channel with logStorage(app, 'label', data).
Next steps
- API reference
- Secure storage: tokens and secrets in the OS vault.
- Offline: keep loaded query data on the device.
- Desktop and Mobile: the platforms that bring native drivers.
- MDN: Using HTTP cookies