Skip to content
Sajjad Javadi

hydrate-storage

Typed, SSR-safe client storage for production web apps. One small core for localStorage, sessionStorage, cookies and in-memory storage, with schema validation, TTL, versioned migrations, pluggable codecs, cross-tab sync and React, Vue and Svelte bindings.

  • Never throws

    Blocked storage, full quota, corrupt data and failed migrations all go through one onError hook.

  • Hydration-safe

    The server and the first client render always see defaultValue, so there is no mismatch.

  • Tiny

    Zero runtime dependencies. About 2.2 kB min+gzip for the core, under 250 B per React or Vue binding.

  • Validated

    Any Standard Schema library works: Zod, Valibot or ArkType. Reads and writes are both checked.

Install

terminalbash
npm install hydrate-storage

Node 18+ for tooling and SSR; any browser with the Web Storage API. react (18+) and vue (3.3+) are optional peer dependencies, needed only for the matching subpath.

Quick start

storage.tsts
import { createStorage } from 'hydrate-storage'import { z } from 'zod' export const storage = createStorage({  namespace: 'myapp',  onError: (error) => logger.warn(error.code, error.message),}) export const settings = storage.item('settings', {  defaultValue: { theme: 'light' as 'light' | 'dark' },  schema: z.object({ theme: z.enum(['light', 'dark']) }),  version: 2,  migrate: (old, from) => (from === 1 ? { theme: old } : old),  ttl: 7 * 24 * 60 * 60 * 1000,}) settings.get()settings.set({ theme: 'dark' })settings.set((prev) => ({ ...prev, theme: 'light' }))settings.remove()settings.subscribe(() => console.log(settings.get()))

Any Standard Schema library works (Zod 3.24+, Valibot, ArkType). Schemas must be synchronous.

Framework guides

React and Next.js

ThemeToggle.tsxtsx
'use client'import { useStorageItem } from 'hydrate-storage/react'import { settings } from './storage' export function ThemeToggle() {  const [value, setSettings] = useStorageItem(settings)  return (    <button onClick={() => setSettings({ theme: value.theme === 'dark' ? 'light' : 'dark' })}>      {value.theme}    </button>  )}

useStorageItem is built on useSyncExternalStore with a server snapshot. In the App Router, hooks live in Client Components; the storage module itself can be imported from Server Components.

Vue and Nuxt

settings.tsts
import { useStorageItem } from 'hydrate-storage/vue' const settings = useStorageItem(settingsItem)settings.value = { theme: 'dark' }

Returns a writable computed. It starts at defaultValue to match server HTML and syncs with storage in onMounted.

Svelte, SvelteKit and Astro

Toggle.sveltesvelte
<script>  import { settings } from './storage'  const store = settings.asStore()</script> <button on:click={() => store.set({ theme: 'dark' })}>{$store.theme}</button>

asStore() implements the Svelte store contract. On the server the first emission is defaultValue.

Vanilla and other frameworks

item.get, item.set and item.subscribe(listener) are enough to build a binding. subscribe returns an unsubscribe function and getServerSnapshot is available for SSR-aware stores.

Data lifecycle

Validation

When schema is set it runs on every read and every write. Invalid writes are rejected and set returns false. Invalid stored data is reported and defaultValue is returned; the bad entry stays in storage until the next successful write.

Versioning and migrations

Every entry records the version it was written with (default 1). Migrations are applied lazily on read; the upgraded form is written on the next set.

Stored versionResult
Equal to currentUsed as is.
Older, migrate providedmigrate(data, fromVersion) runs, then the schema validates the result.
Older, no migrateDiscarded, defaultValue returned.
Newer than currentDiscarded, defaultValue returned. Protects against rollbacks.
migrate throwsA migration error is reported, defaultValue returned.

TTL

ttl is in milliseconds and counted from the last write. Expired entries read as missing and has() is false for them. They are not deleted on read; call storage.purgeExpired(), for example on app start, to reclaim space.

Adapters

AdapterSelected byNotes
Local storage'local' (default)Cross-tab events supported.
Session storage'session'Per tab.
Cookie'cookie'About 4 KB per entry, sent with every request. No cross-tab events.
Memory'memory'Per createStorage call. Good for tests and tooling.
Customany StorageAdapterSynchronous only.

In the browser, if Web Storage is blocked or throws (private mode, sandboxed iframes, disabled cookies) the adapter falls back to in-memory storage for the page's lifetime. On the server every built-in adapter except memory is a no-op.

Error handling

No method throws on storage problems. Failures are passed to onError as a StorageError with a code, the full namespaced key and the original cause.

codeTypical causeResult
unavailableStorage blocked, adapter threw, value could not be serializedset returns false; reads return defaultValue
quotaQuotaExceededErrorset returns false
corruptInvalid JSON, wrong envelope shape, codec failuredefaultValue returned
invalidSchema rejected the value, or the schema is asyncset returns false; reads return defaultValue
migrationmigrate threwdefaultValue returned

API reference

createStorage(options?)

OptionTypeDescription
namespacestringPrefix for every key ("ns:key").
adapter'local' | 'session' | 'cookie' | 'memory' | StorageAdapterBackend. Defaults to 'local'.
serializer{ stringify, parse }Envelope serializer. Defaults to JSON.
codec{ encode, decode }Synchronous payload transform.
onError(error: StorageError) => voidReceives every swallowed failure.
now() => numberClock override for tests.
MethodDescription
item(key, options)Creates a typed StorageItem.
keys()Keys in this namespace, without the prefix.
clear()Removes every key in this namespace and notifies all listeners. Other namespaces are untouched.
purgeExpired()Removes expired entries in this namespace; returns the count.

storage.item(key, options)

OptionTypeDescription
defaultValueT (required)Used on the server, during hydration, and whenever the entry is missing, expired, invalid or incompatible.
schemaStandard SchemaSynchronous validation of reads and writes.
versionnumberData version. Defaults to 1.
migrate(stored, fromVersion) => unknownUpgrades older data.
ttlnumber (ms)Time to live, counted from the last write.
MemberDescription
keyFull key including namespace.
get()Current value or defaultValue. Referentially stable while data is unchanged.
set(valueOrUpdater)Writes. Returns true on success, false on any failure. Notifies subscribers.
remove()Deletes the entry and notifies subscribers.
has()true only for a present, unexpired, valid entry.
subscribe(listener)Fires on local writes and on changes from other tabs. Returns an unsubscribe function.
getServerSnapshot()Always defaultValue.
asStore()Svelte store contract: { subscribe(run), set(value) }.

Exports

createStorage, createMemoryAdapter, noopAdapter, createWebAdapter, createCookieAdapter, StorageError and the types StorageAdapter, Serializer, Codec, StorageOptions, ItemOptions, StorageItem, Storage, StorageErrorCode, AdapterKind, CookieOptions and StandardSchemaV1. Subpaths: hydrate-storage/react and hydrate-storage/vue, each exporting useStorageItem.

SSR guarantees and limits

SSR and runtime behaviour

SituationBehaviour
No window (server, edge, workers)Reads return defaultValue, writes are dropped, subscribe is a no-op. Nothing is shared between requests.
First client rendergetServerSnapshot returns defaultValue, matching server HTML.
Web Storage blocked or private modeIn-memory fallback for the page's lifetime.
Quota exceededset returns false and reports quota. Existing data is kept.
Corrupt or foreign data under a keydefaultValue is returned and corrupt is reported. The entry is not deleted.
Same key written in another tabSubscribers are notified through the storage event.

The server never reads cookies. If a value must be known during SSR, such as a theme, read it with your framework's cookie API and pass it as the render input.

Limitations

  • Everything is synchronous; async backends (IndexedDB, React Native AsyncStorage) and async schemas are not supported.
  • Web Storage is limited to a few MB per origin and cookies to about 4 KB per entry.
  • Cross-tab sync works for adapters that provide subscribe: local, and custom adapters.
  • Expired entries are removed only by purgeExpired() or by overwriting them.
  • Do not store secrets or session tokens in JavaScript-readable storage. Use HttpOnly cookies for those.

Source and license

MIT licensed. Full README, changelog and issue tracker are on GitHub (opens in a new tab); the package is on npm (opens in a new tab).

path:~/sajjad/home