localStorage vs sessionStorageThe browser gives you two key-value stores for persisting data on the client: localStorage and sessionStorage. Both are synchronous, string-only, and limited to the same origin. The difference is lifetime.
localStorage |
sessionStorage |
|
|---|---|---|
| Lifetime | Until explicitly cleared | Until the tab is closed |
| Scope | All tabs on the same origin | Only the tab that created it |
| Survives page reload | Yes | Yes |
| Survives tab close | Yes | No |
| Survives browser close | Yes | No |
When to use localStorage: data you want to survive across sessions - a user's theme preference, a remembered username, a cached draft, a JWT (with the caveats discussed in Authentication).
When to use sessionStorage: data that should only last for the current browsing session - a wizard's in-progress step, temporary form state, a session-scoped UI configuration. It's automatically cleaned up when the user closes the tab, which is exactly the right behaviour for short-lived data.
Both APIs have the same interface: setItem, getItem, removeItem, and clear.
// Strings
localStorage.setItem('theme', 'dark')
// Objects - must be serialised to a string
localStorage.setItem('user', JSON.stringify({ id: 'user_123', name: 'Alice' }))
// Strings - straightforward
const theme = localStorage.getItem('theme') // 'dark' | null
// Objects - must be parsed, and the key might not exist
const raw = localStorage.getItem('user')
const user = raw ? JSON.parse(raw) : null
getItem returns null (not undefined) when a key doesn't exist. Always guard for this before parsing.
JSON.parse throws if the stored string is malformed. Storage writes can fail if the user's disk is full or if the browser is in a private mode with storage blocked. Wrap both in try/catch:
function getStoredUser() {
try {
const raw = localStorage.getItem('user')
if (!raw) return null
return JSON.parse(raw) as { id: string; name: string }
} catch {
// Malformed JSON or localStorage unavailable
return null
}
}
function setStoredUser(user: { id: string; name: string }) {
try {
localStorage.setItem('user', JSON.stringify(user))
} catch {
// Storage quota exceeded or blocked
console.warn('Could not persist user to localStorage')
}
}
This pattern - try to read, fall back to null on failure - is the safest way to work with localStorage. Never let a storage error crash your app.
<aside> 💡
A utility module for storage access pays off quickly once you have more than two or three keys. Put your getStoredX and setStoredX functions in a single file like lib/storage.ts so the try/catch logic lives in one place and the rest of your app just calls the helpers.
</aside>
localStorage and sessionStorage are browser APIs. They don't exist on the server. In a Next.js app, any code that runs during server-side rendering - Server Components, middleware, or even Client Components during their initial render - does not have access to window.
If you access localStorage directly in a module that gets server-rendered, you'll get:
ReferenceError: localStorage is not defined
typeof window !== 'undefined' guardfunction getTheme(): string {
if (typeof window === 'undefined') return 'light' // server default
return localStorage.getItem('theme') ?? 'light'
}
This returns a safe default on the server and reads from storage in the browser.
useEffect pattern for Client ComponentsIn a Client Component, server-render happens before the browser runs useEffect. Read storage inside useEffect, not at the top of the component:
'use client'
import { useState, useEffect } from 'react'
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('light') // safe default for server render
useEffect(() => {
// Runs only in the browser, after hydration
const stored = localStorage.getItem('theme')
if (stored) setTheme(stored)
}, [])
return (
<div data-theme={theme}>
{children}
</div>
)
}
The first render always uses the default value. Once the component mounts in the browser, useEffect runs and updates state with the stored value. This causes a second render - sometimes called a "hydration mismatch" - but it's the correct pattern for reading client-only storage.
<aside> ⚠️
If you read localStorage during the initial render (outside useEffect) in a Client Component, Next.js will throw a hydration error because the server-rendered HTML won't match what the browser renders after reading storage. Always use useEffect or the typeof window guard.
</aside>
Not everything belongs in the browser's storage. Some data is too sensitive, too large, or too temporary to be stored client-side.
Sensitive data - Passwords, credit card numbers, social security numbers, health data, and similar PII should never be in localStorage. There is no access control beyond same-origin, and XSS can exfiltrate everything.
Auth tokens (with caution) - JWTs in localStorage are readable by any JavaScript on the page. This is acceptable for low-stakes apps and prototypes, but production systems with sensitive user data should use httpOnly cookies instead. See Authentication for the full trade-off discussion.
Large objects - localStorage is typically limited to 5–10 MB per origin. Trying to store large datasets, images, or binary data will hit the quota limit and throw. Use IndexedDB for larger structured data.
Derived or re-fetchable data - If data can be fetched from the server on demand, don't cache it in localStorage unless you have a clear caching strategy. Stale cached data with no invalidation mechanism creates subtle bugs that are hard to diagnose.
Anything that changes frequently - localStorage has no pub/sub mechanism. If the same key is written from multiple tabs or components, readers don't automatically know it changed. (You can listen to the storage event, but it only fires in other tabs, not the one that wrote it.)
<aside> ⚠️
Run localStorage.clear() or open DevTools → Application → Storage during development whenever your data model changes. Stale data from a previous schema is a common source of mysterious bugs - your new code tries to read a field that didn't exist when the old data was written.
</aside>
The HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with ❤️ by the HackYourFuture community · Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.