Skip to main content

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:

javascript
// 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:

jsx
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:

jsx
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:

jsx
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:

jsx
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:

jsx
// 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:

jsx
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:

jsx
// 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:

jsx
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:

jsx
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:

jsx
// 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:

jsx
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:

  1. SSR Limitations: Server-side rendered pages can't access localStorage, leading to hydration issues if not handled properly.

  2. Size Limitations: localStorage is limited to around 5MB per domain.

  3. Security Concerns: Don't store sensitive information in localStorage as it's accessible via JavaScript.

  4. Performance: Extensive use of localStorage can impact performance since it's synchronous.

  5. 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ā€‹

  1. Create a theme switcher that toggles between light and dark mode using localStorage to remember the user's preference.

  2. Build a "Recently Viewed Products" feature that stores the last 5 products a user viewed using localStorage.

  3. 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.

  4. Extend the shopping cart example to include a "Save for Later" feature using a separate localStorage key.

  5. 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! :)