Next.js Local Storage
Introductionā
Local Storage is a powerful browser API that allows you to store key-value pairs in a web browser with no expiration date. When working with Next.js applications, using Local Storage requires special consideration due to Next.js's server-side rendering (SSR) capabilities. In this guide, we'll explore how to effectively use Local Storage in Next.js applications, common pitfalls to avoid, and best practices to follow.
Understanding the Challenge with Next.js and Local Storageā
Next.js renders components on the server by default, but localStorage
is only available in browser environments. This creates a fundamental challenge:
// This will cause errors during server-side rendering
const data = localStorage.getItem('user'); // š« Error: localStorage is not defined
The error occurs because when Next.js renders your component on the server, there is no window
or localStorage
object available.
Safe Approaches to Using Local Storage in Next.jsā
Method 1: Client-Side Only Accessā
The safest approach is to ensure localStorage is only accessed after the component has mounted in the browser:
import { useState, useEffect } from 'react';
function ProfilePage() {
const [username, setUsername] = useState('');
useEffect(() => {
// This code runs only on the client after the component mounts
const storedUsername = localStorage.getItem('username');
if (storedUsername) {
setUsername(storedUsername);
}
}, []);
const saveUsername = (newName) => {
setUsername(newName);
localStorage.setItem('username', newName);
};
return (
<div>
<h1>User Profile</h1>
<p>Username: {username || 'Not set'}</p>
<input
value={username}
onChange={(e) => saveUsername(e.target.value)}
placeholder="Enter username"
/>
</div>
);
}
export default ProfilePage;
Method 2: Creating a Custom Hookā
For reusability, you can create a custom hook that safely uses localStorage:
import { useState, useEffect } from 'react';
// Custom hook for using localStorage in Next.js
function useLocalStorage(key, initialValue) {
// State to store our value
const [storedValue, setStoredValue] = useState(initialValue);
// Initialize the state
useEffect(() => {
try {
const item = window.localStorage.getItem(key);
setStoredValue(item ? JSON.parse(item) : initialValue);
} catch (error) {
console.log(error);
return initialValue;
}
}, [key, initialValue]);
// Return a wrapped version of useState's setter function
const setValue = (value) => {
try {
// Allow value to be a function so we have the same API as useState
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.log(error);
}
};
return [storedValue, setValue];
}
Using the hook is straightforward:
function ThemeSettings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<div>
<h2>Current Theme: {theme}</h2>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</div>
);
}
Method 3: Using Dynamic Importsā
You can use dynamic imports to create components that only run on the client side:
import dynamic from 'next/dynamic';
// This component will only be imported and rendered on the client
const ClientOnlyComponent = dynamic(
() => import('../components/ClientComponent'),
{ ssr: false }
);
function HomePage() {
return (
<div>
<h1>Welcome to my Next.js app</h1>
<ClientOnlyComponent />
</div>
);
}
And in your ClientComponent.js
:
// components/ClientComponent.js
import { useState, useEffect } from 'react';
const ClientComponent = () => {
const [savedData, setSavedData] = useState('');
useEffect(() => {
setSavedData(localStorage.getItem('savedData') || '');
}, []);
const updateData = (data) => {
setSavedData(data);
localStorage.setItem('savedData', data);
};
return (
<div>
<h2>Client-only Component with Local Storage</h2>
<input
value={savedData}
onChange={(e) => updateData(e.target.value)}
placeholder="Type to save to localStorage"
/>
<p>Saved value: {savedData}</p>
</div>
);
};
export default ClientComponent;
Best Practices for Local Storage in Next.jsā
1. Always Check for Window Objectā
When directly accessing localStorage, always check if the window object exists:
const saveToStorage = (key, value) => {
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, value);
}
};
const getFromStorage = (key) => {
if (typeof window !== 'undefined') {
return window.localStorage.getItem(key);
}
return null;
};
2. Handle JSON Data Properlyā
When storing objects or arrays, remember to stringify and parse:
// Saving an object
const saveUserData = (user) => {
if (typeof window !== 'undefined') {
window.localStorage.setItem('user', JSON.stringify(user));
}
};
// Retrieving an object
const getUserData = () => {
if (typeof window !== 'undefined') {
const userData = window.localStorage.getItem('user');
return userData ? JSON.parse(userData) : null;
}
return null;
};
3. Combine with React Context for App-wide Stateā
For more complex applications, consider combining localStorage with React Context:
import { createContext, useState, useEffect, useContext } from 'react';
// Create context
const AppStateContext = createContext();
// Provider component
export function AppStateProvider({ children }) {
const [user, setUser] = useState(null);
useEffect(() => {
// Load from localStorage on mount
try {
const storedUser = localStorage.getItem('user');
if (storedUser) {
setUser(JSON.parse(storedUser));
}
} catch (error) {
console.error('Error loading user from localStorage:', error);
}
}, []);
const updateUser = (newUser) => {
setUser(newUser);
// Save to localStorage
if (newUser) {
localStorage.setItem('user', JSON.stringify(newUser));
} else {
localStorage.removeItem('user');
}
};
const logout = () => {
setUser(null);
localStorage.removeItem('user');
};
return (
<AppStateContext.Provider value={{ user, updateUser, logout }}>
{children}
</AppStateContext.Provider>
);
}
// Custom hook to use the context
export function useAppState() {
return useContext(AppStateContext);
}
Then in your pages or components:
import { useAppState } from '../context/AppStateContext';
function UserProfile() {
const { user, updateUser, logout } = useAppState();
if (!user) {
return <p>Please log in to view your profile</p>;
}
return (
<div>
<h1>Welcome, {user.name}!</h1>
<button onClick={logout}>Logout</button>
</div>
);
}
Real-world Example: Shopping Cartā
Here's a practical example of implementing a shopping cart with localStorage in Next.js:
// hooks/useCart.js
import { useState, useEffect } from 'react';
export function useCart() {
const [cart, setCart] = useState([]);
const [loaded, setLoaded] = useState(false);
// Load cart from localStorage on mount
useEffect(() => {
try {
const savedCart = localStorage.getItem('cart');
if (savedCart) {
setCart(JSON.parse(savedCart));
}
} catch (error) {
console.error('Failed to load cart:', error);
} finally {
setLoaded(true);
}
}, []);
// Update localStorage when cart changes
useEffect(() => {
if (loaded) {
localStorage.setItem('cart', JSON.stringify(cart));
}
}, [cart, loaded]);
const addToCart = (product) => {
setCart(prevCart => {
const existingItem = prevCart.find(item => item.id === product.id);
if (existingItem) {
return prevCart.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
} else {
return [...prevCart, { ...product, quantity: 1 }];
}
});
};
const removeFromCart = (productId) => {
setCart(prevCart => prevCart.filter(item => item.id !== productId));
};
const updateQuantity = (productId, quantity) => {
if (quantity < 1) {
removeFromCart(productId);
return;
}
setCart(prevCart =>
prevCart.map(item =>
item.id === productId ? { ...item, quantity } : item
)
);
};
const clearCart = () => {
setCart([]);
};
const totalItems = cart.reduce((total, item) => total + item.quantity, 0);
const totalPrice = cart.reduce(
(total, item) => total + item.price * item.quantity,
0
);
return {
cart,
addToCart,
removeFromCart,
updateQuantity,
clearCart,
totalItems,
totalPrice,
loaded
};
}
And to use this hook in a shopping cart component:
import { useCart } from '../hooks/useCart';
function ShoppingCart() {
const {
cart,
removeFromCart,
updateQuantity,
clearCart,
totalItems,
totalPrice,
loaded
} = useCart();
if (!loaded) {
return <div>Loading cart...</div>;
}
if (cart.length === 0) {
return <div>Your cart is empty</div>;
}
return (
<div className="cart">
<h1>Your Shopping Cart ({totalItems} items)</h1>
{cart.map(item => (
<div key={item.id} className="cart-item">
<img src={item.image} alt={item.name} width={50} height={50} />
<div>
<h3>{item.name}</h3>
<p>${item.price}</p>
</div>
<div>
<button onClick={() => updateQuantity(item.id, item.quantity - 1)}>-</button>
<span>{item.quantity}</span>
<button onClick={() => updateQuantity(item.id, item.quantity + 1)}>+</button>
</div>
<button onClick={() => removeFromCart(item.id)}>Remove</button>
</div>
))}
<div className="cart-summary">
<h2>Total: ${totalPrice.toFixed(2)}</h2>
<button onClick={clearCart}>Clear Cart</button>
<button>Checkout</button>
</div>
</div>
);
}
export default ShoppingCart;
Limitations and Considerationsā
When using localStorage in Next.js, be aware of these important considerations:
-
SSR Limitations: Server-side rendered pages can't access localStorage, leading to hydration issues if not handled properly.
-
Size Limitations: localStorage is limited to around 5MB per domain.
-
Security Concerns: Don't store sensitive information in localStorage as it's accessible via JavaScript.
-
Performance: Extensive use of localStorage can impact performance since it's synchronous.
-
Privacy Modes: Some browsers clear localStorage in private/incognito modes.
When to Use Alternativesā
Consider alternatives to localStorage when:
- You need server-side persistence: Use a database with API routes
- You need to share state between devices: Use server-state solutions
- You need to store sensitive information: Use HttpOnly cookies or server-side sessions
- You need more storage space: Consider IndexedDB
Summaryā
Working with localStorage in Next.js requires understanding its client-side nature and implementing appropriate safeguards against server-side rendering issues. By using techniques like:
- Checking for the window object before accessing localStorage
- Using useEffect to handle localStorage operations after component mounting
- Creating custom hooks that handle the complexity for you
- Using dynamic imports for client-only components
You can effectively leverage localStorage for persistent client-side state in your Next.js applications.
Additional Resourcesā
Exercisesā
-
Create a theme switcher that toggles between light and dark mode using localStorage to remember the user's preference.
-
Build a "Recently Viewed Products" feature that stores the last 5 products a user viewed using localStorage.
-
Implement a form with localStorage backup that saves form data as the user types, so they can recover their progress if they accidentally navigate away.
-
Extend the shopping cart example to include a "Save for Later" feature using a separate localStorage key.
-
Create a custom React Context that provides localStorage state to your entire application with TypeScript support.
If you spot any mistakes on this website, please let me know at [email protected]. Iād greatly appreciate your feedback! :)