Storage
@rooted/storage wraps the three browser storage APIs (localStorage, sessionStorage, and cookies) in something type-safe. It does not encrypt or sync. It is just a small layer that handles serialisation and gives you typed reads.
pnpm add @rooted/storageImport from @rooted/storage/web:
import { localStorage, sessionStorage, cookieStorage } from '@rooted/storage/web'All three are SSR-safe. When the underlying browser API isn't there (for example during a server render), reads return undefined and writes are no-ops. Nothing throws at import time.
localStorage
Persistent across tabs and reloads. Cleared by the user.
import { localStorage } from '@rooted/storage/web'
localStorage.set('theme', 'dark')
const theme = localStorage.get<'dark' | 'light'>('theme')
// 'dark' | 'light' | undefined
localStorage.removeItem('theme')get<T> returns undefined if the key is missing. The type parameter tells TypeScript what to expect. Strings round-trip as-is; everything else is JSON-encoded on write and JSON-parsed on read.
get and set are paired with the lower-level getItem and setItem that work in raw strings, plus keys(), key(index), length, and clear() from the native interface.
sessionStorage
Same shape as localStorage, but scoped to the tab and cleared when the tab closes.
import { sessionStorage } from '@rooted/storage/web'
sessionStorage.set('draft', { title: 'WIP' })
const draft = sessionStorage.get<{ title: string }>('draft')
sessionStorage.removeItem('draft')cookieStorage
Cookies are different from the other two. They have an expiry, a path, a SameSite policy, and a Secure flag. The API has two set overloads: one for plain reads/writes, and one that takes a full cookie init object.
import { cookieStorage } from '@rooted/storage/web'
// Plain set: name + value. The path is filled in for you.
cookieStorage.set('locale', 'en-GB')
// Full init: pass attributes through to the browser.
cookieStorage.set({
name: 'session',
value: { id: 7 },
path: '/account',
sameSite: 'lax',
secure: true,
})
const locale = cookieStorage.get<string>('locale')
cookieStorage.removeItem('session', { path: '/account' })A few things to know:
- The init form accepts everything in the standard
CookieInit:domain,path,expires,sameSite,secure, and any new field the browser adds. get<T>parses JSON, just likelocalStorage.get. Strings come back as strings. Objects come back as their original shape.- Every cookie gets a
Path, and it defaults to the app root. Leave it off and the cookie is readable everywhere in the app. Without one the browser would scope the cookie to the directory of the page that set it, so a cookie written on/recipes/pancakeswould be invisible on/, which is rarely what anyone means. - The app root comes from Vite's
base, so an app served from/my-repo/getsPath=/my-repoand doesn't share cookies with a different app on the same host. Ifbaseis a full CDN URL or a relative./, there is nothing to derive it from and the path falls back to/. - A
pathyou pass yourself is read relative to the app too, the same way@rooted/routerreads an href. In an app served from/my-repo/,path: '/settings',path: 'settings'andpath: '/my-repo/settings'all end up as/my-repo/settings. There is no way to write a cookie outside the app base through this API: a path pointing outside it (https://..., or anything with a..segment) throws in development and falls back to the app root in production. removeItem(name, { domain, path })matches on the whole tuple. Thepathis resolved the same way it is on write, so a cookie you set without one is removed without one too. You do still need to passdomainwhen the cookie was written with it.names()returns the list of cookie names visible to the page.all()returns aMap<string, string>of every cookie's raw value.
Combining with a store
Storage is not reactive. Put a store in front of it if you want the rest of the app to react to changes.
import { createStore } from '@rooted/store'
import { localStorage } from '@rooted/storage/web'
const KEY = 'theme'
type Theme = 'dark' | 'light'
export const theme = createStore<Theme>(localStorage.get<Theme>(KEY) ?? 'light')
theme.on('change', ({ detail }) => {
localStorage.set(KEY, detail.state)
})This is module scope, so the subscription leaves the signal out and is cleaned up on page unload. See state for when to pass one.
The recipe-book example does this for the per-recipe servings count. See recipe.mts for the full pattern.
Trade-offs
- Synchronous reads.
localStorageblocks the main thread. Don't use it for hot paths. - No encryption. Don't store secrets.
- No quota handling. If the user's storage is full,
setItemthrows. Wrap it intry/catchif you write user-generated content. - Reads are protected against prototype-pollution by a JSON reviver, but that does not make stored data trustworthy. Validate it before using it.