Docs
/
Angular
Chapter 22
22 — SSR & SSG (Server-Side Rendering)
Why SSR?
| Client-Side (SPA) | SSR | SSG | |
|---|---|---|---|
| First paint | Slow (JS download → render) | Fast (HTML from server) | Fastest (pre-built HTML) |
| SEO | Poor (empty HTML) | Excellent | Excellent |
| Dynamic data | Yes | Yes (per request) | No (build time only) |
| Server cost | None | Per request | None (static hosting) |
| Best for | Dashboards, internal apps | Public pages, e-commerce | Blogs, docs, marketing |
Setup
ng add @angular/ssr
This generates:
src/app/app.config.server.ts— server-side providersserver.ts— Express server entry point- Updated
angular.jsonwith SSR builder
Configuration
app.config.ts (Client)
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withFetch()),
provideClientHydration(withEventReplay()), // Reuse server HTML
],
};
app.config.server.ts (Server)
import { provideServerRendering } from '@angular/platform-server';
import { provideServerRoutesConfig } from '@angular/ssr';
import { serverRoutes } from './app.routes.server';
export const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
provideServerRoutesConfig(serverRoutes),
],
};
app.routes.server.ts — Route Rendering Config
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
// SSG — pre-render at build time
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'about', renderMode: RenderMode.Prerender },
{ path: 'pricing', renderMode: RenderMode.Prerender },
// SSR — render on each request
{ path: 'dashboard', renderMode: RenderMode.Server },
{ path: 'users/:id', renderMode: RenderMode.Server },
// Client-only — no server rendering
{ path: 'admin/**', renderMode: RenderMode.Client },
// Catch-all
{ path: '**', renderMode: RenderMode.Server },
];
Hydration
Hydration is the process where Angular reuses the server-rendered DOM instead of destroying and re-creating it.
Server: Renders full HTML → sends to browser
Client: Angular bootstraps → finds existing DOM → attaches event listeners
Without hydration: visible flicker as Angular destroys server HTML and re-renders. With hydration: seamless — no flicker.
Incremental Hydration (Angular 19+)
<!-- Hydrate on viewport (lazy) -->
@defer (hydrate on viewport) {
<app-product-list />
}
<!-- Hydrate on interaction -->
@defer (hydrate on interaction) {
<app-comment-section />
}
<!-- Never hydrate (pure static content) -->
@defer (hydrate never) {
<app-footer />
}
Platform Detection (Server vs Browser)
Some browser APIs (window, localStorage, document) don't exist on the server.
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { PLATFORM_ID, inject } from '@angular/core';
@Component({ ... })
export class MyComponent {
private platformId = inject(PLATFORM_ID);
ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
// Safe to use browser APIs
window.scrollTo(0, 0);
localStorage.setItem('visited', 'true');
}
}
}
afterNextRender / afterRender (Angular 16+)
import { afterNextRender, afterRender } from '@angular/core';
@Component({ ... })
export class ChartComponent {
constructor() {
// Runs ONCE after first render — browser only
afterNextRender(() => {
this.initChart(); // Safe to access DOM
});
// Runs after EVERY render — browser only
afterRender(() => {
this.updateChartSize();
});
}
}
Transfer State (Avoid Double Fetch)
Without transfer state, data fetched on the server is fetched again on the client.
import { makeStateKey, TransferState } from '@angular/core';
const USERS_KEY = makeStateKey<User[]>('users');
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
private transferState = inject(TransferState);
private platformId = inject(PLATFORM_ID);
getAll(): Observable<User[]> {
// Check if data was already fetched on server
const cached = this.transferState.get(USERS_KEY, null);
if (cached) {
this.transferState.remove(USERS_KEY); // Use once
return of(cached);
}
return this.http.get<User[]>('/api/users').pipe(
tap(users => {
if (isPlatformServer(this.platformId)) {
this.transferState.set(USERS_KEY, users); // Store for client
}
}),
);
}
}
> Angular 17+ with provideHttpClient(withFetch()) handles transfer state automatically for HTTP requests.
SEO — Meta Tags
import { Meta, Title } from '@angular/platform-browser';
@Component({ ... })
export class ProductDetailComponent {
private meta = inject(Meta);
private title = inject(Title);
ngOnInit() {
this.title.setTitle('Product Name - My Store');
this.meta.updateTag({ name: 'description', content: 'Product description...' });
this.meta.updateTag({ property: 'og:title', content: 'Product Name' });
this.meta.updateTag({ property: 'og:image', content: 'https://...' });
this.meta.updateTag({ name: 'robots', content: 'index, follow' });
}
}
Dynamic Meta via Resolver
// route config
{
path: 'products/:slug',
loadComponent: () => import('./product-detail.component'),
resolve: { product: productResolver },
data: { title: 'Product Detail' },
}
// Global title strategy
@Injectable()
export class PageTitleStrategy extends TitleStrategy {
private title = inject(Title);
updateTitle(snapshot: RouterStateSnapshot) {
const title = this.buildTitle(snapshot);
this.title.setTitle(title ? `${title} — My App` : 'My App');
}
}
// Provide
providers: [
{ provide: TitleStrategy, useClass: PageTitleStrategy },
]
Build & Deploy
# Build with SSR
ng build
# Run SSR server
node dist/my-app/server/server.mjs
# Deploy to:
# - Node.js server (Express)
# - Firebase Hosting + Cloud Functions
# - Vercel (auto-detects Angular SSR)
# - Netlify (with serverless functions)
# - Docker
Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
EXPOSE 4000
CMD ["node", "dist/my-app/server/server.mjs"]
Key Takeaways
- SSR renders pages on each request — best for dynamic, SEO-critical pages
- SSG (Prerender) generates static HTML at build time — fastest for static pages
- Hydration reuses server-rendered DOM — eliminates flash of content
- Use
afterNextRender()instead ofisPlatformBrowser()for DOM access provideHttpClient(withFetch())auto-handles transfer state in Angular 17+- Use
RenderModeinapp.routes.server.tsto mix SSR, SSG, and client-only per route - Set meta tags with
TitleandMetaservices for SEO