If an AI assistant built your Next.js side project on localStorage, it probably works fine until a corrupt value, a full quota, a second tab, or Safari's 7-day cleanup wipes someone's data without a single error on screen. Here are six checks, each with a tested fix and a prompt you can paste into Cursor, Claude Code, or ChatGPT.
The examples come from a small budget app (expenses, categories, monthly budgets) built prompt by prompt with no backend. Everything below was run in headless Chromium unless it says otherwise.
1. "Return an empty array on bad JSON" deletes the data on the next save
JSON.parse throws on anything malformed, so assistants wrap it and fall back to []. That's the right instinct, and it's what our own test app did. A smoke test confirmed the page shows "No expenses yet" instead of crashing.
The problem is the next line of code that runs. The user adds an expense, the app pushes it onto that empty array and saves. In our test, a truncated value with two records, [{"id":1},{"id":2}, became [{"id":3}] after one add. The two old records were gone, and nothing ever said so.
Keep the broken value before you replace it:
export function load<T>(key: string): { ok: boolean; items: T[] } {
if (typeof window === "undefined") return { ok: true, items: [] };
const raw = localStorage.getItem(key);
if (raw === null) return { ok: true, items: [] };
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return { ok: true, items: parsed as T[] };
} catch {
// fall through and keep a copy below
}
localStorage.setItem(`${key}:corrupt-${Date.now()}`, raw); // keep the evidence
return { ok: false, items: [] };
}
When ok is false, show a banner ("Some saved data couldn't be read. A copy was kept.") so the user knows to export before adding more.
In
lib/storage.ts, when a stored value is missing, return[]. When it's present but invalid JSON or not an array, copy the raw string to<key>:corrupt-<timestamp>before returning[], and return a flag so the page can show a warning banner. Never overwrite a value you couldn't parse without keeping a copy.
2. setItem can throw, and the UI keeps pretending it saved
MDN puts localStorage at up to 5 MiB per origin, and setItem throws QuotaExceededError when you go over. In our test, writing values of a million characters each, the fifth setItem threw:
QuotaExceededError: Failed to execute 'setItem' on 'Storage':
Setting the value of 'big4' exceeded the quota.
A text-only budget app takes a long time to get there. Add something like receipt photos as base64 strings and you'll get there much sooner. The usual AI-generated code updates React state first and calls setItem after, with no try/catch. The new row shows up on screen and is gone on refresh.
Make every save report whether it worked, and only update the UI from storage:
export function save<T>(key: string, items: T[]): boolean {
try {
localStorage.setItem(key, JSON.stringify(items));
return true;
} catch {
window.dispatchEvent(new CustomEvent("app:storage-error", {
detail: "Could not save. Storage may be full.",
}));
return false;
}
}
Our test app does exactly this, with a small banner component that listens for the event.
Wrap every
localStorage.setItemintry/catchand returntrueorfalse. On failure, dispatch awindowevent, and add a layout-level banner that listens for it and says "Could not save. Storage may be full." After saving, reload the list from storage instead of trusting the state you just set.
3. Every project on localhost:3000 shares one storage
Storage belongs to an origin, which is scheme plus hostname plus port. Your budget app, last week's todo app, and the tutorial you cloned yesterday all ran on http://localhost:3000, so they all read and write the same localStorage.
In our test, a second tab on the same host and port saw a key set by the first. A tab on port 3082 saw nothing, and so did 127.0.0.1 on the same port, because that's a different hostname. Then localStorage.clear() from one "project" also wiped an unrelated theme key.
Two rules fix this. Prefix every key with the app name (vibe-budget:expenses, not expenses). And never call localStorage.clear() in a "Reset data" button. Remove only your own keys:
Object.keys(localStorage)
.filter((k) => k.startsWith("vibe-budget:") && k !== "vibe-budget:theme")
.forEach((k) => localStorage.removeItem(k));
In the test, that removed the app's data and left both other-app:todos and the saved theme alone.
Every
localStoragekey in this project must start with<app-name>:. Never calllocalStorage.clear(). "Clear all data" removes only keys with our prefix, keeps the theme key, then re-seeds defaults.
4. Two open tabs overwrite each other
Most generated pages load data once in useEffect, keep it in state, and save the whole array from state on every change. Open the app in two tabs and that becomes last-write-wins with stale data.
We reproduced it with two tabs. Tab B added an item and saved. Tab A, still holding the old array in memory, added its own item and saved. B's item disappeared.
Two small changes cover it. First, read the latest value from storage right before you write, instead of saving whatever is in state:
function addExpense(e: Expense) {
const current = loadExpenses(); // re-read, don't trust state
saveExpenses([...current, e]);
setExpenses(loadExpenses());
}
With that change the same test kept all three items. Second, listen for the storage event so the other tab refreshes:
useEffect(() => {
const onStorage = (ev: StorageEvent) => {
if (ev.key?.startsWith("vibe-budget:")) setExpenses(loadExpenses());
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
The storage event fires in the other tabs of the same origin, not in the tab that made the change. Our test confirmed that, so don't count on it to refresh the tab doing the writing.
Every add, edit, or delete must read the current array from storage, apply the change, save it, then reload state from storage. Also add a
storageevent listener on each page that reloads its data when a key with our prefix changes in another tab.
5. Import accepts the file, but not safely
Export/import is the only backup a localStorage app has, so it's worth checking twice. Our test app does the basics well: it checks that expenses, categories, and budgets are arrays with the right field types, rejects a bad file, and leaves existing data untouched. Export, clear site data, refresh, import, and everything came back.
Two gaps were still there when we read the code. The export writes version: 1, but import never looks at it. So the day you rename a field, an old backup will either fail validation or, worse, pass it. And import writes three keys one after another without checking whether each save worked. If the second write hits the quota, you end up with new categories and old expenses.
In import: reject files whose
versionis missing or higher than the current one, and run amigrate(data, fromVersion)step for older versions. Before writing anything, keep a copy of the current data in memory. Save all three keys, check eachsave()result, and if any fails, restore the copy and show an error.
6. Safari deletes it after 7 days without a visit
This is the one you can't fix in code. WebKit's tracking prevention deletes all of a site's script-writable storage, localStorage included, "after seven days of Safari use without user interaction on the site." MDN describes the same proactive eviction and notes that only Safari does it. A web app added to the Home Screen has its own day counter, so it isn't affected the same way.
So a Safari user who forgets your budget app for a week and a half can come back to an empty app. Private windows are worse: their localStorage is gone when the last private tab closes.
The honest fix is to tell users and make backups easy:
- Say in the UI and README that data lives only in this browser, and recommend Export.
- Show "Last backup: never" or a date on the settings page, and nudge after a week.
- If you can't afford to lose the data, it needs a server. That's a different app.
Add a "Data is stored only in this browser" note on the settings page with an Export button next to it. Store the date of the last export under
<app-name>:lastExportand show it. If it's more than 7 days old or missing, show a small reminder to export.
Put the rules where the assistant reads them every time
Ten prompts in, an assistant may no longer follow storage rules you gave it back in prompt 3. Put them in AGENTS.md or .cursor/rules/storage.mdc (with alwaysApply: true) so they're loaded on every request:
Storage rules:
- All keys start with "vibe-budget:". Never call localStorage.clear().
- Never overwrite a value you couldn't parse; back it up first.
- setItem is always in try/catch and returns true/false.
- Read from storage right before every write; reload state after.
- Finish every change with: npm run lint && npm run build
Then test the failure paths on purpose. Put {corrupt in a key from DevTools and reload. Open two tabs and add something in each. Clear site data and import your last export. If you only test the happy path, you won't find any of these bugs.
If you want to see the storage layer prompt our test app was built from, it's prompt 3 in the free Vibe Budget App sample, along with the scaffold, types, seed data, and navigation prompts. It covers the parse fallback and prefixed keys, not the corrupt-value backup from section 1, so add that line yourself.
Top comments (0)