DEV Community

Deek Roumy
Deek Roumy

Posted on

Angular SSR: The localStorage Trap That Breaks Your App in Production

Your Angular app works perfectly in development. Every feature, every theme toggle, every user preference loads exactly as expected. You deploy to production with SSR enabled, feeling confident.

Then the crash reports start rolling in. Users can't load the page. The server logs show a cryptic error: ReferenceError: localStorage is not defined.

What happened? You fell into one of Angular SSR's most common traps — and I'm going to show you exactly how to avoid it.

A Quick Refresher on SSR

If you're reading this, you probably already know what Server-Side Rendering is. But let's make sure we're on the same page.

With Angular Universal (now built into Angular as @angular/ssr), your app renders on the server first. The server generates HTML, sends it to the browser, and the client-side Angular takes over (hydration). This gives you:

  • Faster First Contentful Paint — users see content before JavaScript loads
  • Better SEO — search engines can crawl fully rendered pages
  • Improved perceived performance — the page appears ready immediately

The catch? Your Angular code now runs in two very different environments: Node.js on the server, and the browser on the client.

And those environments don't have the same APIs.

The localStorage Trap

Here's a pattern I see constantly in Angular apps. You want to persist a user's theme preference:

// theme.service.ts
@Injectable({ providedIn: 'root' })
export class ThemeService {
  private currentTheme: 'light' | 'dark' = 'light';

  constructor() {
    this.currentTheme = this.resolveInitialTheme();
  }

  private resolveInitialTheme(): 'light' | 'dark' {
    // 💥 This line will crash on the server
    const saved = localStorage.getItem('theme');
    if (saved === 'light' || saved === 'dark') {
      return saved;
    }
    return 'light';
  }

  setTheme(theme: 'light' | 'dark') {
    this.currentTheme = theme;
    localStorage.setItem('theme', theme);
  }
}
Enter fullscreen mode Exit fullscreen mode

This code is perfectly reasonable. It works flawlessly when you run ng serve and test in your browser.

But when Angular Universal tries to render this on the server, you get:

ReferenceError: localStorage is not defined
    at ThemeService.resolveInitialTheme (theme.service.ts:12:19)
    at new ThemeService (theme.service.ts:8:26)
Enter fullscreen mode Exit fullscreen mode

Your production server crashes. Users see a blank page or an error. Your deployment just failed.

Why This Happens

The issue is fundamental to how SSR works.

In the browser:

  • window exists
  • document exists
  • localStorage exists
  • navigator exists
  • All the Web APIs you know and love are available

On the server (Node.js):

  • None of these exist
  • There's no window object
  • There's no document
  • There's definitely no localStorage

When Angular renders your component on the server, it executes your TypeScript code in Node.js. Node.js doesn't have browser APIs because... it's not a browser.

The moment your code tries to access localStorage, Node.js throws a ReferenceError because that global doesn't exist. Your server crashes, and the request fails.

The Fix: isPlatformBrowser()

Angular provides a built-in solution: the isPlatformBrowser() function from @angular/common.

Here's the fixed version:

// theme.service.ts
import { Injectable, PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Injectable({ providedIn: 'root' })
export class ThemeService {
  private currentTheme: 'light' | 'dark' = 'light';
  private platformId = inject(PLATFORM_ID);

  constructor() {
    this.currentTheme = this.resolveInitialTheme();
  }

  private resolveInitialTheme(): 'light' | 'dark' {
    // ✅ Guard browser-only APIs
    if (isPlatformBrowser(this.platformId)) {
      const saved = localStorage.getItem('theme');
      if (saved === 'light' || saved === 'dark') {
        return saved;
      }
    }
    // Return a sensible default for server-side rendering
    return 'light';
  }

  setTheme(theme: 'light' | 'dark') {
    this.currentTheme = theme;
    if (isPlatformBrowser(this.platformId)) {
      localStorage.setItem('theme', theme);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's break down what changed:

  1. Inject PLATFORM_ID — Angular provides this token that identifies the current platform
  2. Import isPlatformBrowser — This function checks if you're running in a browser
  3. Guard every browser API call — Wrap localStorage access in the platform check
  4. Provide a fallback — Return a sensible default when running on the server

The server renders with the default theme. When the client hydrates, it reads the actual preference from localStorage and updates if needed. Smooth, no crashes.

Other Browser-Only APIs to Watch For

localStorage isn't the only trap. Here's a checklist of browser APIs that will crash your SSR app:

The Usual Suspects

// ❌ All of these are undefined on the server
window.innerWidth
window.scrollTo(0, 0)
document.getElementById('my-element')
document.querySelector('.my-class')
navigator.userAgent
navigator.geolocation
sessionStorage.getItem('key')
Enter fullscreen mode Exit fullscreen mode

The Fix Pattern

Every browser API access needs the same guard:

import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID, inject } from '@angular/core';

// In your component or service
private platformId = inject(PLATFORM_ID);

someMethod() {
  if (isPlatformBrowser(this.platformId)) {
    // Safe to use browser APIs here
    const width = window.innerWidth;
    const element = document.querySelector('.my-class');
  }
}
Enter fullscreen mode Exit fullscreen mode

Common Patterns That Need Guards

  • Theme/dark mode toggles — reading preferences from localStorage
  • Scroll position restoration — using window.scrollTo
  • Analytics initialization — many analytics SDKs access window
  • Feature detection — checking navigator.userAgent
  • DOM measurements — reading element dimensions
  • Clipboard operations — navigator.clipboard
  • Media queries — window.matchMedia

Testing SSR Locally

Don't wait until production to discover SSR issues. Test locally:

# Build with SSR
ng build

# Serve the SSR build
npm run serve:ssr:your-app-name

# Or use the dev server with SSR
ng serve --configuration=development
Enter fullscreen mode Exit fullscreen mode

If your app has SSR issues, you'll see them immediately in your terminal. The server will crash or log errors when it tries to render.

Pro tip: Search your codebase for direct uses of localStorage, sessionStorage, window, document, and navigator. Every hit is a potential SSR crash waiting to happen.

# Quick audit
grep -r "localStorage\|sessionStorage\|window\.\|document\.\|navigator\." src/app/
Enter fullscreen mode Exit fullscreen mode

A Real-World Example

This isn't theoretical — I recently fixed this exact bug in an open-source project.

The ng-pdf-viewer-workspace project had a theme service that accessed localStorage without platform checks. In SSR mode, it would crash on the first request.

The fix? Exactly what I showed above — adding isPlatformBrowser() guards around localStorage access in resolveInitialTheme().

You can see the actual PR here: PR #9 - Fix SSR localStorage guard

It's a small change — a few lines of code. But it's the difference between a working production app and a broken one.

Conclusion

The localStorage trap catches almost every Angular developer at some point. The pattern is seductive: you write code that works perfectly in development, deploy with SSR enabled, and watch it crash.

The fix is simple once you know it:

  1. Inject PLATFORM_ID
  2. Use isPlatformBrowser() before any browser API
  3. Always provide a server-safe fallback

One check. A few lines of code. Saves hours of debugging production crashes.

If you're building an Angular app with SSR, audit your codebase today. Find every localStorage, window, and document call. Guard them all. Your production users (and your on-call rotation) will thank you.


Found this helpful? I write about Angular, TypeScript, and the weird bugs that waste our time. Follow for more.

Top comments (0)