Skip to content

Fix: Angular SSR Not Working — Hydration Failing, Window Not Defined, or Build Errors

FixDevs ·

Quick Answer

How to fix Angular Server-Side Rendering issues — @angular/ssr setup, hydration, platform detection, transfer state, route-level rendering, and deployment configuration.

The Problem

Angular SSR renders but hydration fails with warnings:

Angular hydration expected a text node but found <div>

Or the server crashes:

ReferenceError: window is not defined
ReferenceError: document is not defined

Or SSR returns a blank page:

The server responds with HTML but the content is empty

Why This Happens

Angular SSR (previously Angular Universal) renders Angular components on the server using Node.js. Since Angular 17+, SSR uses @angular/ssr:

  • Browser APIs don’t exist on the serverwindow, document, localStorage, and other browser globals are undefined in Node.js. Code that accesses them during server rendering crashes.
  • Hydration requires matching DOM — Angular 16+ has full hydration support. The server-rendered HTML must match what the client would render. Differences cause hydration errors or a full re-render.
  • Third-party libraries may not be SSR-compatible — libraries that access the DOM directly (chart libraries, map libraries) cause server crashes.
  • Transfer state prevents duplicate API calls — without TransferState, the server fetches data and renders HTML, then the client fetches the same data again. Transfer state serializes server data into the HTML for the client to reuse.

Fix 1: Enable SSR in Angular 17+

# New project with SSR
ng new my-app --ssr

# Add SSR to existing project
ng add @angular/ssr
// angular.json — SSR is configured automatically
{
  "projects": {
    "my-app": {
      "architect": {
        "build": {
          "options": {
            "server": "src/main.server.ts",
            "prerender": true,
            "ssr": {
              "entry": "server.ts"
            }
          }
        }
      }
    }
  }
}
// src/app/app.config.ts — client config with hydration
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideClientHydration(withEventReplay()),  // Enable hydration
    provideHttpClient(withFetch()),
  ],
};
// src/app/app.config.server.ts — server config
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { provideServerRoutesConfig } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(),
    provideServerRoutesConfig(serverRoutes),
  ],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

Fix 2: Platform Detection

import { Component, PLATFORM_ID, Inject, inject, afterNextRender } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';

@Component({
  selector: 'app-chart',
  template: `
    @if (isBrowser) {
      <div #chartContainer></div>
    } @else {
      <div class="chart-placeholder">Loading chart...</div>
    }
  `,
})
export class ChartComponent {
  private platformId = inject(PLATFORM_ID);
  isBrowser = isPlatformBrowser(this.platformId);

  constructor() {
    // afterNextRender — runs only in the browser after hydration
    afterNextRender(() => {
      // Safe to use window, document, localStorage
      this.initializeChart();
    });
  }

  private initializeChart() {
    // Browser-only code
    const container = document.querySelector('#chartContainer');
    // Initialize chart library...
  }
}

// Alternative: use afterRender for recurring effects
import { afterRender } from '@angular/core';

@Component({ /* ... */ })
export class ScrollTracker {
  constructor() {
    afterRender(() => {
      // Runs after every render in the browser
      console.log('Scroll position:', window.scrollY);
    });
  }
}

Fix 3: Route-Level Rendering Strategy

// src/app/app.routes.server.ts — control SSR per route
import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  // Pre-render at build time (static)
  { path: '', renderMode: RenderMode.Prerender },
  { path: 'about', renderMode: RenderMode.Prerender },
  { path: 'pricing', renderMode: RenderMode.Prerender },

  // Server-side render on each request
  { path: 'dashboard', renderMode: RenderMode.Server },
  { path: 'profile', renderMode: RenderMode.Server },

  // Client-only rendering (no SSR)
  { path: 'admin/**', renderMode: RenderMode.Client },

  // Pre-render with dynamic params
  {
    path: 'blog/:slug',
    renderMode: RenderMode.Prerender,
    async getPrerenderParams() {
      const posts = await fetchAllPosts();
      return posts.map(post => ({ slug: post.slug }));
    },
  },

  // Catch-all — server render everything else
  { path: '**', renderMode: RenderMode.Server },
];

Fix 4: Data Fetching with Transfer State

// Angular 17+ automatically transfers HttpClient responses
// Just use provideHttpClient(withFetch()) — transfer state is built-in

@Component({
  selector: 'app-posts',
  template: `
    @if (posts(); as postList) {
      @for (post of postList; track post.id) {
        <article>
          <h2>{{ post.title }}</h2>
          <p>{{ post.excerpt }}</p>
        </article>
      }
    } @else {
      <p>Loading...</p>
    }
  `,
})
export class PostsComponent {
  private http = inject(HttpClient);

  // Using signals (Angular 17+)
  posts = toSignal(
    this.http.get<Post[]>('/api/posts'),
    { initialValue: null }
  );
}

// Manual transfer state (for non-HTTP data)
import { TransferState, makeStateKey } from '@angular/core';

const USERS_KEY = makeStateKey<User[]>('users');

@Component({ /* ... */ })
export class UsersComponent implements OnInit {
  private transferState = inject(TransferState);
  private platformId = inject(PLATFORM_ID);
  users: User[] = [];

  async ngOnInit() {
    // Check if data was transferred from server
    if (this.transferState.hasKey(USERS_KEY)) {
      this.users = this.transferState.get(USERS_KEY, []);
      this.transferState.remove(USERS_KEY);
      return;
    }

    // Fetch on server, store for client
    const users = await fetchUsers();
    this.users = users;

    if (isPlatformServer(this.platformId)) {
      this.transferState.set(USERS_KEY, users);
    }
  }
}

Fix 5: Handling Third-Party Libraries

// Strategy 1: Lazy import in afterNextRender
@Component({
  selector: 'app-map',
  template: '<div id="map" style="height: 400px;"></div>',
})
export class MapComponent {
  constructor() {
    afterNextRender(async () => {
      const L = await import('leaflet');
      const map = L.map('map').setView([51.505, -0.09], 13);
      L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
    });
  }
}

// Strategy 2: Client-only component via route config
// In app.routes.server.ts:
{ path: 'map', renderMode: RenderMode.Client }

// Strategy 3: Structural directive
@Component({
  selector: 'app-editor',
  template: `
    <div *ngIf="isBrowser">
      <ng-container *ngComponentOutlet="editorComponent" />
    </div>
    <div *ngIf="!isBrowser">
      <p>Editor loading...</p>
    </div>
  `,
})
export class EditorWrapper {
  isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
  editorComponent: any;

  constructor() {
    afterNextRender(async () => {
      const { RichTextEditor } = await import('./rich-text-editor.component');
      this.editorComponent = RichTextEditor;
    });
  }
}

Fix 6: Deployment

# Build with SSR
ng build

# Output structure:
# dist/my-app/
# ├── browser/    # Client-side assets
# └── server/     # Server bundle

# Run the production server
node dist/my-app/server/server.mjs
// server.ts — Express server (auto-generated)
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr/node';
import express from 'express';

const server = express();
const commonEngine = new CommonEngine();

server.get('**', (req, res, next) => {
  commonEngine
    .render({
      bootstrap,
      documentFilePath: indexHtml,
      url: req.url,
      providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }],
    })
    .then(html => res.send(html))
    .catch(err => next(err));
});

server.listen(4000);
// Deploy to different platforms:
// Vercel: ng add @angular/ssr --server-routing
// Netlify: use @netlify/angular
// Firebase: ng add @angular/fire

Still Not Working?

“window is not defined” — direct window access crashes the server. Use isPlatformBrowser() to guard browser-only code, or use afterNextRender() which only runs in the browser.

Hydration errors — the server and client render different HTML. Common causes: *ngIf based on isPlatformBrowser (server renders false, client renders true), async data loading without transfer state, or time-dependent content (dates, randomness).

SSR returns blank HTML — the server build might have errors. Check dist/my-app/server/ exists. Also verify that provideServerRendering() is in the server config and routes are configured in app.routes.server.ts.

API calls happen twice — without transfer state, the server fetches data and renders HTML, then the client fetches the same data again. Use provideHttpClient(withFetch()) in Angular 17+ — it automatically transfers HTTP responses from server to client.

For related Angular issues, see Fix: Angular Signals Not Updating and Fix: Next.js App Router Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles