DEV Community

Cover image for Browser Storage Doesn't Scale with Your Team. So I Built namespaced-storage
Shayan Mirzaie
Shayan Mirzaie

Posted on

Browser Storage Doesn't Scale with Your Team. So I Built namespaced-storage

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");
Enter fullscreen mode Exit fullscreen mode

Later, the authentication team writes:

localStorage.setItem("status", "verified");
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

The browser now contains explicit ownership:

basket:count
basket:items
auth:token
ui:theme
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For ESLint 9 flat config:

// eslint.config.js
import nss from "eslint-plugin-namespaced-storage";

export default [nss.configs.recommended];
Enter fullscreen mode Exit fullscreen mode

The plugin includes four rules:

  • no-direct-storage prevents raw localStorage and sessionStorage access
  • require-namespace-literal keeps namespaces statically discoverable
  • no-reserved-key protects internal metadata
  • storage-file-convention keeps declarations in *.storage.ts files 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').
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It exits with code 1, so the same check works in CI:

- run: npx nss scan
Enter fullscreen mode Exit fullscreen mode

You can also generate a committed Markdown inventory:

npx nss docs -o docs/storage.md
git diff --exit-code docs/storage.md
Enter fullscreen mode Exit fullscreen mode

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.

  1. Install namespaced-storage.
  2. Choose one feature with clear ownership.
  3. Move its reads and writes into a *.storage.ts file.
  4. Add defaults or schemas for its keys.
  5. Enable the recommended ESLint config.
  6. Allow legacy access temporarily where migration is incomplete.
  7. Run nss scan in CI.
  8. 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)