Adds rich-value serialization, expiration, namespacing, and schema validation to localStorage.

Get started at ultrastorage.dev for setup instructions, guides, and examples.

Documentation · API reference · React guide

- Store anything: Stores Set,Map,Date,RegExp,BigInt, circular references, and more using devalue

- TTL / expiration: Set a ttlin milliseconds or an absoluteexpiresAttimestamp. Expired items are treated as missing, can be removed ongetItem(), and can be swept withclearExpired()

- Namespacing: Group related keys with a prefixand scope operations such asclear()to that namespace

- Array keys: Build structural keys from ordered string segments without separator collisions

- Subscriptions: React to key changes within a page and across tabs sharing localStorage

- React hooks: Optional ultrastorage/reactadapter with typed values, functional updates, and server rendering

- Schema validation: Validate retrieved values against any Standard Schema with synchronous validation (Zod, Valibot, ArkType, etc.)

- Choose where values are saved: Use localStorage,sessionStorage, any object that implementsStorage, or the included in-memory implementation

- ESM and CJS: Tree-shakeable dual builds with full TypeScript types

npm install ultrastorageultrastorage was previously published as greatstorage. Existing users can follow the

migration guide.

Internally, every value is stored as an object rather than being written to the storage raw. That object carries the user value, an optional expiry, a version, and an internal __us marker. The marker is there so ultrastorage can reliably tell its own entries apart from unrelated keys already living in the same Storage. The version is there so the on-disk format can evolve later without guessing which shape an older entry used if this library ever needs to change the internal storage structure.

New writes use __us; existing entries marked with __gs remain fully supported without migration.

Refer to the documentation for more explanation regarding internals and design decisions.

The examples below cover the basics. Follow the getting started guide for a walkthrough, or browse the documentation for detailed guides.

Store and retrieve objects without calling JSON.stringify() and JSON.parse().

// lib/app-storage.ts

import { createStorage } from 'ultrastorage';

// Create an app-wide singleton instance.

export const appStorage = createStorage({ prefix: 'acme' });// app.ts

import { appStorage } from './lib/app-storage';

appStorage.setItem('user', { name: 'Alice', age: 30 });

appStorage.getItem('user'); // { name: 'Alice', age: 30 }Use an array of strings when a key has several structural parts. Arrays with the same ordered segments address the same entry, even when they are different array instances.

appStorage.setItem(['users', userId, 'preferences'], { theme: 'dark' });

appStorage.getItem(['users', userId, 'preferences']); // { theme: 'dark' }Array keys use an opaque internal encoding rather than the configured namespace separator, so separator characters and empty strings inside segments are preserved.

Store Set, Map, Date, and other values that JSON does not preserve.

Uses devalue by default, but you can bring your own serializer.

appStorage.setItem('tags', new Set(['a', 'b', 'c']));

appStorage.getItem('tags'); // Set {'a', 'b', 'c'}

appStorage.setItem('metadata', new Map([['key', 'value']]));

appStorage.getItem('metadata'); // Map {'key' => 'value'}

appStorage.setItem('date', new Date('2025-01-01'));

appStorage.getItem('date'); // Date 2025-01-01T00:00:00.000ZSet a lifetime or an absolute expiration time for stored data.

Expired data behaves like a missing key. getItem() removes expired entries on read, while has(), key(), keys(), and length simply ignore them. Use clearExpired() to proactively sweep them.

// Expires in 60 seconds

appStorage.setItem('token', 'abc123', { ttl: 60_000 });

// Expires at a specific date

appStorage.setItem('session', { id: 1 }, { expiresAt: new Date('2025-12-31') });

// Expired items return null

appStorage.getItem('token'); // null (after 60s)Add a prefix to group related keys and avoid collisions with other storage users.

const appStorage = createStorage({ prefix: 'acme' });

appStorage.setItem('theme', 'dark'); // stored as "acme:theme"

appStorage.getItem('theme'); // 'dark'

localStorage.getItem('theme'); // null

// clear() only removes keys within the namespace

appStorage.clear();Subscribe to a key to update other parts of the page when its storage entry changes. Other ultrastorage instances using the same Storage object and fully prefixed key notify the same listeners, even if those instances never subscribe themselves.

const appStorage = createStorage({ prefix: 'app' });

function applyTheme() {

document.documentElement.dataset.theme = appStorage.getItem<string>('theme') ?? 'system';

}

const unsubscribe = appStorage.subscribe('theme', (change) => {

console.log(change.key, change.type, change.source);

// 'theme', 'set' | 'remove' | 'expire', 'local' | 'external'

applyTheme();

});

applyTheme(); // Subscribing does not invoke the listener immediately.Another module can create its own instance with the same prefix and notify the subscriber above:

import { createStorage } from 'ultrastorage';

const appStorage = createStorage({ prefix: 'app' });

appStorage.setItem('theme', 'dark'); // Notifies this page synchronously.When the subscriber is no longer needed, clean it up in the subscribing module:

unsubscribe(); // Safe to call again; each subscription is independent.Tabs sharing localStorage receive changes asynchronously through browser storage events. sessionStorage remains scoped to its tab; in-memory and custom Storage objects support same-page notifications between instances using the exact same object.

Events describe storage changes, without carrying old/new values. Read the current value using getItem(), optionally with a schema. It may reflect a later write by the time you read it. Expiration stays lazy by default. Pass { reactiveExpiration: true } as the third argument to subscribe() to receive expire when the deadline passes, without deleting the entry. React hooks accept the same option. Later cleanup through getItem() or clearExpired() may emit another expire event. Timers may run late.

Check for entries, remove individual entries, or clear a namespace.

appStorage.has('user'); // true

appStorage.removeItem('user');

appStorage.has('user'); // false

appStorage.clear(); // remove all entries written by `ultrastorage`

appStorage.clearExpired(); // remove only expired entriesProvide a type argument to type values returned from storage.

interface User {

name: string;

age: number;

}

const user = appStorage.getItem<User>('user');

// user is typed as User | null

appStorage.getOrInit<User>('user', () => ({ name: 'Alice', age: 30 }));

appStorage.updateItem<User>('user', (current) => ({

...current!,

age: current!.age + 1,

}));However, the true safe way is to validate with a schema during read.

Browser storage can be modified outside your application. Validate reads with any library that supports synchronous Standard Schema validation.

import { z } from 'zod';

const UserSchema = z.object({ name: z.string(), age: z.number() });

// Returns typed value if valid, null if validation fails

const user = appStorage.getItem('user', { schema: UserSchema });Pass localStorage, sessionStorage, or any object that implements Storage. Use createMemoryStorage() for tests and server-side rendering.

import { createStorage, createMemoryStorage } from 'ultrastorage';

const appStorage = createStorage({

prefix: 'acme',

storage: typeof window === 'undefined' ? createMemoryStorage() : undefined,

});Provide custom stringify and parse methods to use another serialization format.

import superjson from 'superjson';

const appStorage = createStorage({

prefix: 'acme',

serializer: { stringify: superjson.stringify, parse: superjson.parse },

});If you provide your own serializer and want to keep devalue out of your bundle entirely, import from ultrastorage/core instead. The only difference is that serializer is required.

import { createStorage } from 'ultrastorage/core';

import superjson from 'superjson';

const appStorage = createStorage({

prefix: 'acme',

serializer: { stringify: superjson.stringify, parse: superjson.parse },

});Use these helpers for common initialization and update operations.

Return the existing value, or create, store, and return a new value when the key is missing.

const prefs = appStorage.getOrInit('prefs', () => ({

theme: 'light',

lang: 'en',

}));Read, update, and write a value in one call.

appStorage.updateItem('count', (current) => (current ?? 0) + 1);See the documentation for the full API reference, React integration, and caveats.

- store2 by Nathan Bubna: feature-rich localStoragewrapper with namespacing and plugins

- store.js by Marcus Westin: cross-browser localStoragewrapper with fallback plugins

- unstorage by UnJS: universal key-value storage with pluggable drivers (memory, filesystem, Redis, etc.)

- storage-box by Shahrad Elahi: simple localStoragewrapper with TTL support

- lscache by Pamela Fox: localStoragewrapper with memcached-inspired expiration

See the roadmap for planned features, the API reference for current behavior, and how it works for design explanations.

This project uses Vite+ with the Node.js version in

.node-version and the package manager declared in package.json.

vp install

vp check

vp test run

vp run test:coverage # enforces 100% runtime coverage

vp packUse vp pack or vp run build to build this library, including ESM, CJS, and

TypeScript declarations. vp build runs Vite's application build and expects an

HTML entry point. Formatting, linting, and packaging options live in vite.config.ts.

MIT