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);
}
}
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)
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:
-
windowexists -
documentexists -
localStorageexists -
navigatorexists - All the Web APIs you know and love are available
On the server (Node.js):
- None of these exist
- There's no
windowobject - 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);
}
}
}
Let's break down what changed:
-
Inject
PLATFORM_ID— Angular provides this token that identifies the current platform -
Import
isPlatformBrowser— This function checks if you're running in a browser -
Guard every browser API call — Wrap
localStorageaccess in the platform check - 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')
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');
}
}
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
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/
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:
- Inject
PLATFORM_ID - Use
isPlatformBrowser()before any browser API - 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)