A few lines of localStorage usually look harmless.
A feature remembers a filter. Authentication keeps a token. The basket survives a refresh.
Each decision makes sense in isolation. But in a large frontend codebase, owned by multiple teams, all of those features write into the same global namespace.
That is where a small browser API becomes an architectural problem.
Working on a large-scale product at Snappfood, I have seen how quickly storage decisions spread across a codebase. Different teams move at different speeds. Features have different lifetimes. Engineers join, leave, and refactor. A key written today may still exist in a user's browser months later.
JavaScript gives us several ways to persist data, but the raw APIs provide very few boundaries:
- Keys are global to the origin
- Values are strings
- Ownership is invisible
-
clear()affects everyone - TypeScript cannot protect data after it crosses the storage boundary
- Nobody can easily answer what the application stores
These problems do not always fail during development.
Sometimes they reach production.
That is why I built namespaced-storage, together with an ESLint plugin and a CLI.
The goal is not to replace localStorage with a prettier API.
The goal is to make browser storage explicit, owned, typed, and reviewable.
The problem starts with one global namespace
Imagine two teams working on different parts of a food delivery application.
The checkout team writes:
localStorage.setItem("status", "pending");
Later, the authentication team writes:
localStorage.setItem("status", "verified");
Both changes look correct in their pull requests. Both may pass every test. In the browser, one silently overwrites the other.
A common response is to add prefixes manually:
localStorage.setItem("checkout:status", "pending");
localStorage.setItem("auth:status", "verified");
This helps, but it is only a naming convention. Every engineer must remember it, spell it correctly, serialize values consistently, and avoid clearing data owned by another feature.
Conventions that live only in documentation slowly disappear.
In a large codebase, a boundary is useful only when the tools can enforce it.
Give every feature an owner
With namespaced-storage, each feature declares its own store:
npm install namespaced-storage
import { createLocalStorage } from "namespaced-storage";
export const basketStorage = createLocalStorage("basket");
basketStorage.set("count", 10);
basketStorage.get("count"); // 10, not "10"
basketStorage.clear(); // clears basket:* only
The browser now contains explicit ownership:
basket:count
basket:items
auth:token
ui:theme
The basket feature can clear its state without removing authentication, UI preferences, or another team's data.
Storage is no longer owned by the application. Each piece of storage is owned by a feature.
I prefer declaring stores next to the feature that owns them:
src/
features/
auth/auth.storage.ts
basket/basket.storage.ts
shared/ui/ui.storage.ts
There is no central registry that every team has to edit. Ownership remains local, while the tooling can still build a global view.
Make keys and values type-safe
Defaults are enough to infer many contracts:
export const basketStorage = createLocalStorage("basket", {
defaults: {
count: 0,
items: [] as BasketItem[],
},
});
basketStorage.get("count");
// number, not number | undefined
basketStorage.set("count", 3); // valid
basketStorage.set("count", "three"); // TypeScript error
For data that needs runtime validation, the package includes a small schema API and also supports Standard Schema validators such as Zod, Valibot, and ArkType:
import { createSessionStorage, t } from "namespaced-storage";
export const authStorage = createSessionStorage("auth", {
schema: {
token: t.string(),
},
});
authStorage.set("token", "jwt-value");
authStorage.set("token", 42);
// compile error and runtime ValidationError
This protects both sides of the boundary:
- TypeScript catches incorrect writes during development
- Runtime validation catches invalid persisted data
It also round-trips regular JSON values plus Date, Map, Set, BigInt, RegExp, NaN, and infinity values.
basketStorage.set("updatedAt", new Date());
const updatedAt = basketStorage.get("updatedAt");
// a real Date instance
A library without enforcement becomes optional
In a multi-team repository, architecture can drift one component at a time.
Someone is fixing an urgent bug. Someone copies an old example. Someone writes directly to window.localStorage because it is faster than finding the abstraction.
Six months later, the project has two storage systems again.
So I built eslint-plugin-namespaced-storage.
npm install --save-dev eslint-plugin-namespaced-storage
For ESLint 9 flat config:
// eslint.config.js
import nss from "eslint-plugin-namespaced-storage";
export default [nss.configs.recommended];
The plugin includes four rules:
-
no-direct-storageprevents raw localStorage and sessionStorage access -
require-namespace-literalkeeps namespaces statically discoverable -
no-reserved-keyprotects internal metadata -
storage-file-conventionkeeps declarations in*.storage.tsfiles in strict mode
Instead of relying on a code review comment, the feedback appears where the mistake is introduced:
Direct use of 'localStorage' is not allowed.
Create a namespaced store instead:
createLocalStorage('feature').
The rule understands scope. A local variable called localStorage is not confused with the browser global, and adapter files can be explicitly allowed when raw access is intentional.
Good guardrails should be precise enough that teams do not need to disable them.
Can we answer what the application stores?
Even with type-safe stores and lint rules, an engineering team still needs visibility.
In a large repository, I want to ask:
- Which namespaces exist?
- Which team owns each one?
- Is the data local or session-scoped?
- Which keys are stored?
- Did two packages declare the same namespace?
- Did a pull request change the persistence surface?
That is what @shayan-mirzaie/cli provides.
npm install --save-dev @shayan-mirzaie/cli
npx nss scan
Example output:
namespaced-storage · 3 namespaces across 3 files
auth session team-identity src/features/auth/auth.storage.ts
basket local team-checkout src/features/basket/basket.storage.ts
ui local team-platform src/shared/ui/ui.storage.ts
The CLI reads the source without running the application. It can scan a repository that does not compile, requires no module resolution, and detects duplicate namespaces across packages in a monorepo.
✖ duplicate namespace "cart" on localStorage
src/features/basket/basket.storage.ts
src/legacy/cart/store.ts
It exits with code 1, so the same check works in CI:
- run: npx nss scan
You can also generate a committed Markdown inventory:
npx nss docs -o docs/storage.md
git diff --exit-code docs/storage.md
Now a pull request can show that a feature started persisting a new field, changed ownership, or introduced another namespace.
Storage becomes reviewable architecture rather than invisible runtime behavior.
A practical adoption path
You do not need to migrate an entire codebase at once.
- Install namespaced-storage.
- Choose one feature with clear ownership.
- Move its reads and writes into a
*.storage.tsfile. - Add defaults or schemas for its keys.
- Enable the recommended ESLint config.
- Allow legacy access temporarily where migration is incomplete.
- Run
nss scanin CI. - Tighten the rules as raw access disappears.
The most valuable first step is not converting every call. It is establishing the ownership model that new code must follow.
What this library is not
Namespacing is discipline, not security.
Any script running on the page can still access browser storage. Sensitive secrets do not become safe because they have a prefix. The package does not replace secure server-side persistence, authorization, or a database.
It is also not a global state-management library.
It solves persistence boundaries. React state, server state, URL state, and workflow state still need the right owners.
The question is not:
How can we put more things in localStorage?
The better question is:
When browser persistence is the right choice, how do we keep it from becoming invisible shared state?
Try it
The project is open source and available now:
The core package has zero runtime dependencies, ships ESM and CJS builds with TypeScript declarations, and includes examples for vanilla TypeScript, React, and Next.js with SSR.
If you work on a large frontend codebase, I would love to hear how your team manages browser storage:
Do you have explicit ownership and tooling around it, or is localStorage still a global namespace held together by conventions?
Top comments (0)