AngularJSServer-Side Rendering

Server-Side Rendering (SSR) in Angular

Angular Universal (now built into Angular itself as of v17) enables Server-Side Rendering (SSR) — the ability to render your Angular application on the server and send fully-formed HTML to the browser.

SSR dramatically improves first contentful paint, SEO, and social media previews by delivering pre-rendered HTML before JavaScript loads.

Why Use SSR?

Concern

Without SSR (CSR)

With SSR

Initial page load

Blank page until JS runs

Pre-rendered HTML immediately

SEO

Crawlers may miss content

Full HTML available to crawlers

Social previews

Empty og:tags

Correct meta tags rendered

Core Web Vitals

Slower LCP

Faster LCP and FCP

JavaScript required

Yes

No — works without JS (progressive)

Adding SSR to a New Project

Angular 17+ generates SSR-ready projects by default. When creating a new project, the CLI prompts:

Bash
ng new my-app
# Would you like to enable Server-Side Rendering (SSR) and Static Site Generation (SSG/Prerendering)?
# > Yes

This creates the project with @angular/ssr configured automatically.

Adding SSR to an Existing Project

Bash
ng add @angular/ssr

This command:

  • Installs @angular/ssr and express
  • Creates server.ts (the Express app entry point)
  • Creates app.config.server.ts (server-side app config)
  • Updates angular.json with SSR build targets
Project Structure After Adding SSR

Bash
my-app/
├── src/
│   ├── app/
│   │   ├── app.config.ts          # Browser config
│   │   ├── app.config.server.ts   # Server config (merges with browser)
│   │   └── app.routes.ts
│   ├── main.ts                    # Browser bootstrap
│   └── main.server.ts             # Server bootstrap
├── server.ts                      # Express server entry point
└── angular.json                   # Updated with SSR targets
app.config.server.ts

The server config extends the browser config with server-specific providers:

TS
// app.config.server.ts
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(),
    // Add server-only providers here
  ],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);
The Express Server (server.ts)

TS
// server.ts
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import bootstrap from './src/main.server';

export function app(): express.Express {
  const server = express();
  const serverDistFolder = dirname(fileURLToPath(import.meta.url));
  const browserDistFolder = resolve(serverDistFolder, '../browser');
  const indexHtml = join(serverDistFolder, 'index.server.html');

  const commonEngine = new CommonEngine();

  server.set('view engine', 'html');
  server.set('views', browserDistFolder);

  // Serve static files
  server.get('*.*', express.static(browserDistFolder, {
    maxAge: '1y',
  }));

  // Render all other routes with Angular
  server.get('*', (req, res, next) => {
    const { protocol, originalUrl, baseUrl, headers } = req;

    commonEngine
      .render({
        bootstrap,
        documentFilePath: indexHtml,
        url: `${protocol}://${headers.host}${originalUrl}`,
        publicPath: browserDistFolder,
        providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
      })
      .then((html) => res.send(html))
      .catch((err) => next(err));
  });

  return server;
}

function run(): void {
  const port = process.env['PORT'] || 4000;
  const server = app();
  server.listen(port, () => {
    console.log(`Node Express server listening on http://localhost:${port}`);
  });
}

run();
Building and Running SSR

Bash
# Build for SSR
ng build

# The build produces two outputs:
# dist/my-app/browser/   — client-side bundle
# dist/my-app/server/    — server-side bundle

# Run the server
node dist/my-app/server/server.mjs
Node Express server listening on http://localhost:4000
Detecting Server vs. Browser

Some operations (localStorage, window, document) are only available in the browser. Use isPlatformBrowser / isPlatformServer to guard them:

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

@Component({ selector: 'app-root', template: '' })
export class AppComponent implements OnInit {
  private platformId = inject(PLATFORM_ID);

  ngOnInit() {
    if (isPlatformBrowser(this.platformId)) {
      // Safe to access window, localStorage, etc.
      const theme = localStorage.getItem('theme');
      console.log('Running in browser');
    }

    if (isPlatformServer(this.platformId)) {
      // Runs on server only
      console.log('Running on server');
    }
  }
}
Warning
Never access window, document, or localStorage directly in SSR-enabled apps. Always guard with isPlatformBrowser or the afterNextRender hook.
Using afterNextRender and afterRender

Angular 16+ provides afterNextRender and afterRender lifecycle hooks that only run in the browser, making them perfect for DOM-dependent code:

TS
import { Component, afterNextRender, ElementRef, inject } from '@angular/core';

@Component({
  standalone: true,
  selector: 'app-chart',
  template: `<canvas #canvas></canvas>`,
})
export class ChartComponent {
  private el = inject(ElementRef);

  constructor() {
    // Only runs in the browser, after the first render
    afterNextRender(() => {
      const canvas = this.el.nativeElement.querySelector('canvas');
      // Initialize chart library here — safe, we're in the browser
      initializeChart(canvas);
    });
  }
}
Static Site Generation (SSG / Prerendering)

SSG pre-renders pages at build time rather than per-request. This is ideal for content that doesn't change per-user (blog posts, docs, marketing pages).

Configure prerendering in angular.json:

JSON
{
  "projects": {
    "my-app": {
      "architect": {
        "build": {
          "options": {
            "prerender": {
              "routesFile": "routes.txt"
            }
          }
        }
      }
    }
  }
}

Text
# routes.txt — list all routes to prerender
/
/about
/blog/post-1
/blog/post-2
/products

Or configure dynamic routes programmatically:

TS
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  {
    path: 'blog/:slug',
    renderMode: RenderMode.Prerender,
    async getPrerenderParams() {
      const posts = await fetchBlogSlugs(); // your API call
      return posts.map((slug) => ({ slug }));
    },
  },
  {
    path: 'dashboard',
    renderMode: RenderMode.Server, // SSR per-request
  },
  {
    path: '**',
    renderMode: RenderMode.Prerender, // SSG for everything else
  },
];
Hydration

Angular's non-destructive hydration (stable in Angular 17) preserves the server-rendered DOM during client-side bootstrap instead of re-rendering from scratch. This prevents content flicker and improves Core Web Vitals.

Enable it in your browser app config:

TS
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideClientHydration } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideClientHydration(),  // Enable non-destructive hydration
  ],
};
Tip
With hydration enabled, Angular reuses the server-rendered DOM nodes. Components must produce identical output on server and client — avoid using Math.random() or Date.now()during initial render.
HTTP Transfer State

Without transfer state, data fetched on the server gets refetched on the client — wasting a round trip. The HttpClient in Angular automatically handles transfer state when SSR is enabled:

TS
// app.config.ts
import { provideHttpClient, withFetch } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(withFetch()), // Uses fetch API on both server and browser
    provideClientHydration(),
  ],
};

With withFetch(), Angular automatically transfers HTTP responses from server to client so the browser doesn't make duplicate API calls.

Deploying SSR Apps

SSR apps require a Node.js environment. Common deployment options:

  • Node.js server (VPS, AWS EC2, DigitalOcean) — run server.mjs directly

  • Docker container — containerize and deploy to Kubernetes, ECS, etc.

  • Google Cloud Run — serverless containers for Node.js

  • Vercel / Netlify — automatic SSR detection and deployment

  • Firebase Hosting + Cloud Functions — deploy with firebase deploy

Bash
# Example: Docker deployment
FROM node:20-alpine
WORKDIR /app
COPY dist/my-app/ ./
EXPOSE 4000
CMD ["node", "server/server.mjs"]
Common SSR Pitfalls

Pitfall

Solution

Accessing window/document

Guard with isPlatformBrowser or afterNextRender

Content flicker on hydration

Enable provideClientHydration()

Server/client HTML mismatch

Avoid random values or Date.now() in templates

Memory leaks in server

Avoid storing state in singleton services across requests

Slow TTFB

Add HTTP caching headers, use CDN for static assets

Third-party libraries failing

Check for browser-only APIs, use isPlatformBrowser

SSR Checklist
  1. Add @angular/ssr to your project with ng add @angular/ssr

  2. Guard all browser-only APIs with isPlatformBrowser

  3. Enable provideClientHydration() in app.config.ts

  4. Use provideHttpClient(withFetch()) for automatic transfer state

  5. Use afterNextRender for DOM-dependent initialization

  6. Test locally with ng build && node dist/.../server.mjs

  7. Configure prerendering routes for static content

  8. Set up proper HTTP caching headers on the server