Supercharged localStorage — store any data type, key expiration, namespacing, and schema validation
90
stars
67
commits
TypeScript
primary language
Sep 13, 2026
updated
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
Set, Map, Date, RegExp, BigInt, circular references, and more using devaluettl in milliseconds or an absolute expiresAt timestamp. Expired items are treated as missing, can be removed on getItem(), and can be swept with clearExpired()prefix and scope operations such as clear() to that namespacelocalStorageultrastorage/react adapter with typed values, functional updates, and server renderinglocalStorage, sessionStorage, any object that implements Storage, or the included in-memory implementationnpm install ultrastorage
ultrastorage 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.000Z
Set 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 entries
Provide 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 });
localStoragePass 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.
appStorage.getOrInit()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',
}));
appStorage.updateItem()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.
localStorage wrapper with namespacing and pluginslocalStorage wrapper with fallback pluginslocalStorage wrapper with TTL supportlocalStorage wrapper with memcached-inspired expirationSee 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 pack
Use 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
67 commits
TypeScript
99.7%
Supercharged localStorage — store any data type, key expiration, namespacing, and schema validation
90
stars
67
commits
TypeScript
primary language
Sep 13, 2026
updated
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
Set, Map, Date, RegExp, BigInt, circular references, and more using devaluettl in milliseconds or an absolute expiresAt timestamp. Expired items are treated as missing, can be removed on getItem(), and can be swept with clearExpired()prefix and scope operations such as clear() to that namespacelocalStorageultrastorage/react adapter with typed values, functional updates, and server renderinglocalStorage, sessionStorage, any object that implements Storage, or the included in-memory implementationnpm install ultrastorage
ultrastorage 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.000Z
Set 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 entries
Provide 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 });
localStoragePass 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.
appStorage.getOrInit()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',
}));
appStorage.updateItem()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.
localStorage wrapper with namespacing and pluginslocalStorage wrapper with fallback pluginslocalStorage wrapper with TTL supportlocalStorage wrapper with memcached-inspired expirationSee 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 pack
Use 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
67 commits
TypeScript
99.7%