LocalStorage Not Working in Next.js App Router: Complete Guide & Solutions

LocalStorage Not Working in Next.js App Router: Complete Guide & Solutions

|By Malik Saqib

Introduction

Are you struggling with localStorage not working in Next.js App Router? You're not alone. Many developers encounter this issue when building applications with Next.js 13+ using the App Router. The problem is simple but often confusing: localStorage is undefined in Next.js App Router because of server-side rendering.

In this comprehensive guide, I'll explain why localStorage fails in Next.js SSR environments and show you multiple solutions to fix it. By the end, you'll understand how to properly implement client-side storage in Next.js App Router without errors.


Why LocalStorage Stops Working in Next.js App Router

Before jumping into solutions, let's understand why localStorage is not available in Next.js App Router.

The Root Cause: Server-Side Rendering (SSR)

Next.js App Router components are server components by default. They run on the server before being sent to the browser. localStorage is a browser API that only exists on the client side. When your server tries to access localStorage, it gets an error because the object doesn't exist in the Node.js environment.

Here's what happens:

  1. Server renders the component - localStorage doesn't exist in Node.js
  2. Error thrown: ReferenceError: localStorage is not defined
  3. Component fails to hydrate properly on the client
  4. User sees a blank page or console errors

This is the core reason why localStorage not working Next.js frustrates developers new to SSR environments.


The Problem Explained: Common Errors

Error 1: Direct localStorage Access

// ❌ This will FAIL in Next.js App Router
export default function Component() {
  const data = localStorage.getItem('key'); // ERROR on server!
  return <div>{data}</div>;
}

Error message: ReferenceError: localStorage is not defined

Error 2: Inside useEffect (Still Problematic Without 'use client')

// ❌ Still fails without 'use client' directive
import { useEffect, useState } from 'react';
 
export default function Component() {
  const [data, setData] = useState(null);
 
  useEffect(() => {
    const stored = localStorage.getItem('key');
    setData(stored);
  }, []);
 
  return <div>{data}</div>;
}

Prerequisites

To follow this guide on how to fix localStorage in Next.js, you'll need:

  • Next.js 13+ with App Router configured
  • Basic understanding of React hooks
  • Knowledge of server vs. client components
  • Node.js and npm installed

Solution 1: Use the 'use client' Directive (Recommended)

The simplest way to fix localStorage not working in Next.js App Router is to mark the component as a client component using the 'use client' directive.

How It Works

When you add 'use client' at the top of a file, Next.js treats it as a client-side component. This prevents server-side rendering and lets localStorage work normally.

Implementation

Create a new file: components/ClientComponent.jsx

'use client';
 
import { useEffect, useState } from 'react';
 
export default function ClientComponent() {
  const [data, setData] = useState(null);
  const [isMounted, setIsMounted] = useState(false);
 
  useEffect(() => {
    // Set mounted flag to prevent hydration mismatch
    setIsMounted(true);
    
    // Now localStorage is safe to use
    const stored = localStorage.getItem('userPreference');
    setData(stored);
  }, []);
 
  // Prevent rendering until component is mounted on client
  if (!isMounted) {
    return <div>Loading...</div>;
  }
 
  return (
    <div>
      <p>Stored data: {data}</p>
    </div>
  );
}

Why This Works

  • 'use client' directive moves rendering to the browser
  • useEffect only runs on the client after hydration
  • isMounted check prevents hydration mismatches

Solution 2: Create a Custom useLocalStorage Hook

For reusable localStorage functionality in Next.js App Router, create a custom hook that handles all edge cases.

The useLocalStorage Hook

Create hooks/useLocalStorage.js:

'use client';
 
import { useEffect, useState } from 'react';
 
export function useLocalStorage(key, initialValue) {
  // State to store our value
  const [storedValue, setStoredValue] = useState(initialValue);
  
  // State to track if component is mounted (prevents hydration mismatch)
  const [isMounted, setIsMounted] = useState(false);
 
  // Initialize on client mount
  useEffect(() => {
    setIsMounted(true);
    
    try {
      // Check if localStorage is available
      if (typeof window === 'undefined') {
        return;
      }
 
      // Get stored value from localStorage
      const item = window.localStorage.getItem(key);
      
      if (item) {
        setStoredValue(JSON.parse(item));
      }
    } catch (error) {
      console.error(`Error reading localStorage key "${key}":`, error);
    }
  }, [key]);
 
  // Function to set value in localStorage
  const setValue = (value) => {
    try {
      if (typeof window === 'undefined') {
        console.warn('localStorage is not available on server');
        return;
      }
 
      // Allow value to be a function for same API as useState
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(`Error setting localStorage key "${key}":`, error);
    }
  };
 
  // Don't render until mounted to avoid hydration mismatch
  return isMounted ? [storedValue, setValue] : [initialValue, setValue];
}

Usage Example

Create components/UserPreferences.jsx:

'use client';
 
import { useLocalStorage } from '@/hooks/useLocalStorage';
 
export function UserPreferences() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const [language, setLanguage] = useLocalStorage('language', 'en');
 
  const handleThemeChange = (newTheme) => {
    setTheme(newTheme);
  };
 
  return (
    <div>
      <h2>Preferences</h2>
      
      <div>
        <label htmlFor="theme">Theme: </label>
        <select 
          id="theme"
          value={theme} 
          onChange={(e) => handleThemeChange(e.target.value)}
        >
          <option value="light">Light</option>
          <option value="dark">Dark</option>
          <option value="auto">Auto</option>
        </select>
      </div>
 
      <div>
        <label htmlFor="language">Language: </label>
        <select 
          id="language"
          value={language} 
          onChange={(e) => setLanguage(e.target.value)}
        >
          <option value="en">English</option>
          <option value="es">Español</option>
          <option value="fr">Français</option>
        </select>
      </div>
 
      <p>Current Theme: {theme}</p>
      <p>Current Language: {language}</p>
    </div>
  );
}

Why This Hook is Better

  • ✅ Handles localStorage not working in SSR automatically
  • ✅ Prevents hydration mismatch in Next.js
  • ✅ Checks if window exists before accessing localStorage
  • ✅ Has error handling and logging
  • ✅ Same API as useState for familiarity

Solution 3: Check if Window Is Defined

For localStorage access in Next.js, always check if window is defined before using it.

Safe localStorage Access Pattern

'use client';
 
import { useEffect, useState } from 'react';
 
export function SafeStorageComponent() {
  const [isLoaded, setIsLoaded] = useState(false);
  const [savedValue, setSavedValue] = useState('');
 
  useEffect(() => {
    // Ensure we're on the client side
    if (typeof window !== 'undefined') {
      const value = localStorage.getItem('myKey') || '';
      setSavedValue(value);
      setIsLoaded(true);
    }
  }, []);
 
  const handleSave = () => {
    if (typeof window !== 'undefined') {
      localStorage.setItem('myKey', 'new value');
      setSavedValue('new value');
    }
  };
 
  if (!isLoaded) {
    return <p>Loading preferences...</p>;
  }
 
  return (
    <div>
      <p>Saved Value: {savedValue}</p>
      <button onClick={handleSave}>Save to localStorage</button>
    </div>
  );
}

Key Points

  • typeof window !== 'undefined' checks if code runs on client
  • This pattern prevents localStorage is not defined Next.js errors
  • Safe for server-side rendering environments

Solution 4: Wrap localStorage in a Context Provider

For managing localStorage across Next.js App Router, use React Context for global state management.

Create a Storage Context

Create context/StorageContext.jsx:

'use client';
 
import { createContext, useContext, useEffect, useState } from 'react';
 
const StorageContext = createContext();
 
export function StorageProvider({ children }) {
  const [isClient, setIsClient] = useState(false);
  const [storage, setStorage] = useState({});
 
  useEffect(() => {
    setIsClient(true);
    // Load all stored data on mount
    if (typeof window !== 'undefined') {
      const allData = {};
      for (let i = 0; i < localStorage.length; i++) {
        const key = localStorage.key(i);
        allData[key] = localStorage.getItem(key);
      }
      setStorage(allData);
    }
  }, []);
 
  const getItem = (key) => {
    if (!isClient) return null;
    return typeof window !== 'undefined' ? localStorage.getItem(key) : null;
  };
 
  const setItem = (key, value) => {
    if (!isClient || typeof window === 'undefined') return;
    localStorage.setItem(key, value);
    setStorage(prev => ({ ...prev, [key]: value }));
  };
 
  const removeItem = (key) => {
    if (!isClient || typeof window === 'undefined') return;
    localStorage.removeItem(key);
    setStorage(prev => {
      const newStorage = { ...prev };
      delete newStorage[key];
      return newStorage;
    });
  };
 
  const clear = () => {
    if (!isClient || typeof window === 'undefined') return;
    localStorage.clear();
    setStorage({});
  };
 
  return (
    <StorageContext.Provider value={{ getItem, setItem, removeItem, clear, isClient }}>
      {children}
    </StorageContext.Provider>
  );
}
 
export function useStorage() {
  const context = useContext(StorageContext);
  if (!context) {
    throw new Error('useStorage must be used within StorageProvider');
  }
  return context;
}

Use the Context in Layout

Update app/layout.jsx:

import { StorageProvider } from '@/context/StorageContext';
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <StorageProvider>
          {children}
        </StorageProvider>
      </body>
    </html>
  );
}

Use in Components

'use client';
 
import { useStorage } from '@/context/StorageContext';
 
export function MyComponent() {
  const { getItem, setItem } = useStorage();
 
  const handleSave = () => {
    setItem('userName', 'John Doe');
  };
 
  const handleLoad = () => {
    const name = getItem('userName');
    console.log('Loaded name:', name);
  };
 
  return (
    <div>
      <button onClick={handleSave}>Save User Name</button>
      <button onClick={handleLoad}>Load User Name</button>
    </div>
  );
}

Solution 5: Use IndexedDB or SessionStorage for Large Data

When localStorage limitations in Next.js become a problem, consider alternatives for large data storage in Next.js.

Using SessionStorage

'use client';
 
import { useEffect, useState } from 'react';
 
export function SessionStorageExample() {
  const [data, setData] = useState(null);
 
  useEffect(() => {
    if (typeof window !== 'undefined') {
      // Use sessionStorage (cleared when tab closes)
      const stored = sessionStorage.getItem('tempData');
      setData(stored);
    }
  }, []);
 
  const handleSave = () => {
    if (typeof window !== 'undefined') {
      sessionStorage.setItem('tempData', 'temporary value');
      setData('temporary value');
    }
  };
 
  return (
    <div>
      <p>Session Data: {data}</p>
      <button onClick={handleSave}>Save to SessionStorage</button>
    </div>
  );
}

Using IndexedDB for Complex Data

'use client';
 
import { useEffect, useState } from 'react';
 
export function IndexedDBExample() {
  const [db, setDb] = useState(null);
 
  useEffect(() => {
    if (typeof window === 'undefined') return;
 
    const request = indexedDB.open('MyDatabase', 1);
 
    request.onupgradeneeded = () => {
      const database = request.result;
      if (!database.objectStoreNames.contains('items')) {
        database.createObjectStore('items', { keyPath: 'id' });
      }
    };
 
    request.onsuccess = () => {
      setDb(request.result);
    };
 
    request.onerror = () => {
      console.error('IndexedDB opening failed');
    };
  }, []);
 
  const saveData = (item) => {
    if (!db) return;
    const transaction = db.transaction(['items'], 'readwrite');
    const store = transaction.objectStore('items');
    store.add(item);
  };
 
  return (
    <div>
      <p>IndexedDB connection: {db ? 'Connected' : 'Disconnected'}</p>
      <button onClick={() => saveData({ id: 1, name: 'Item 1' })}>
        Save to IndexedDB
      </button>
    </div>
  );
}

Troubleshooting: Common Issues & Fixes

Issue 1: Hydration Mismatch Error

Error: Hydration failed because the initial UI does not match what was rendered on the server

Fix: Always use the isMounted pattern:

'use client';
 
import { useEffect, useState } from 'react';
 
export function Component() {
  const [isMounted, setIsMounted] = useState(false);
 
  useEffect(() => {
    setIsMounted(true);
  }, []);
 
  if (!isMounted) return null;
 
  return <div>{localStorage.getItem('key')}</div>;
}

Issue 2: localStorage is undefined

Error: Cannot read properties of undefined (reading 'getItem')

Fix: Check if window exists:

if (typeof window !== 'undefined') {
  const value = localStorage.getItem('key');
}

Issue 3: Values Not Persisting

Problem: localStorage not persisting data in Next.js App Router

Fix: Ensure 'use client' directive is present and check browser's localStorage settings:

'use client';
 
useEffect(() => {
  try {
    localStorage.setItem('test', 'value');
    const retrieved = localStorage.getItem('test');
    console.log('localStorage working:', retrieved === 'value');
  } catch (e) {
    console.error('localStorage error:', e);
  }
}, []);

Issue 4: Private/Incognito Mode

Problem: localStorage not available in private mode browsers

Solution: Implement graceful fallback:

'use client';
 
function canUseLocalStorage() {
  try {
    const test = '__test__';
    localStorage.setItem(test, test);
    localStorage.removeItem(test);
    return true;
  } catch {
    return false;
  }
}
 
export function SafeComponent() {
  const [data, setData] = useState(null);
 
  useEffect(() => {
    if (typeof window !== 'undefined' && canUseLocalStorage()) {
      setData(localStorage.getItem('key'));
    }
  }, []);
 
  return <div>{data || 'Storage not available'}</div>;
}

Best Practices for localStorage in Next.js App Router

1. Always Use 'use client' Directive

'use client'; // Required for browser APIs
 
import { useEffect, useState } from 'react';

2. Check Window Before Accessing localStorage

if (typeof window !== 'undefined') {
  localStorage.getItem('key');
}

3. Prevent Hydration Mismatches

const [isMounted, setIsMounted] = useState(false);
 
useEffect(() => {
  setIsMounted(true);
}, []);
 
if (!isMounted) return null;

4. Use Custom Hooks for Reusability

// Encapsulate localStorage logic
export function useLocalStorage(key, initialValue) {
  // ... hook implementation
}

5. Add Error Handling

try {
  localStorage.setItem(key, value);
} catch (error) {
  console.error('localStorage error:', error);
}

6. Serialize Complex Data

// Always use JSON for complex objects
localStorage.setItem('user', JSON.stringify(userData));
const user = JSON.parse(localStorage.getItem('user'));

Performance Tips: Optimizing localStorage Usage

Debounce Writes to localStorage

'use client';
 
import { useEffect, useState, useRef } from 'react';
 
export function DebouncedStorageComponent() {
  const [value, setValue] = useState('');
  const timeoutRef = useRef(null);
 
  useEffect(() => {
    clearTimeout(timeoutRef.current);
    
    timeoutRef.current = setTimeout(() => {
      if (typeof window !== 'undefined') {
        localStorage.setItem('value', value);
      }
    }, 500); // Wait 500ms before writing
 
    return () => clearTimeout(timeoutRef.current);
  }, [value]);
 
  return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}

Cache localStorage Values in State

'use client';
 
import { useEffect, useState } from 'react';
 
export function CachedStorageComponent() {
  const [cachedValue, setCachedValue] = useState(null);
 
  useEffect(() => {
    if (typeof window !== 'undefined') {
      // Read once and cache
      const value = localStorage.getItem('key');
      setCachedValue(value);
    }
  }, []);
 
  // Use cachedValue instead of repeatedly reading localStorage
  return <div>{cachedValue}</div>;
}

SEO & Accessibility Considerations

When implementing localStorage in Next.js App Router, consider:

  • SSR-first design: Don't rely on localStorage for critical page content
  • Progressive enhancement: Ensure core functionality works without localStorage
  • Fallback UI: Show loading states while localStorage loads
  • Error messages: Provide clear feedback if storage isn't available
  • Keyboard navigation: Ensure preference controls are keyboard accessible
'use client';
 
export function AccessiblePreferences() {
  const [isMounted, setIsMounted] = useState(false);
 
  useEffect(() => {
    setIsMounted(true);
  }, []);
 
  // Show default content during SSR and hydration
  if (!isMounted) {
    return (
      <div role="status" aria-live="polite">
        <p>Loading your preferences...</p>
      </div>
    );
  }
 
  return (
    <div role="region" aria-label="User Preferences">
      {/* Component content */}
    </div>
  );
}

Comparison: Solutions at a Glance

| Solution | Best For | Complexity | SSR Safe | |----------|----------|-----------|----------| | 'use client' directive | Simple components | Low | ✅ Yes | | useLocalStorage hook | Reusable logic | Medium | ✅ Yes | | typeof window check | Conditional access | Low | ✅ Yes | | Context Provider | Global state | High | ✅ Yes | | SessionStorage/IndexedDB | Large data | Medium | ✅ Yes |


Conclusion

LocalStorage not working in Next.js App Router is a common issue with a simple solution. By understanding that Next.js renders server components by default, and using the 'use client' directive combined with proper checking patterns, you can fix this problem completely.

The key takeaways for using localStorage in Next.js App Router are:

  1. Add 'use client' directive to components using browser APIs
  2. Always check typeof window !== 'undefined' before accessing localStorage
  3. Use the isMounted pattern to prevent hydration mismatches
  4. Create custom hooks for reusable localStorage logic
  5. Implement proper error handling and fallbacks

Start with the simple 'use client' solution, and graduate to custom hooks as your application grows. Your users will thank you for the persistent preferences and improved experience!

Author

Malik Saqib

I craft short, practical AI & web dev articles. Follow me on LinkedIn.