Blog with SSR
A blog is the ideal project for learning server-side rendering because search engines and social-media crawlers need real HTML, not an empty <app-root> waiting for JavaScript. In this guide you will build an SEO-friendly blog with Angular’s built-in SSR, render markdown-style content on the server, hydrate it on the client without re-rendering, and set per-page titles and meta tags. By the end you will understand the full request lifecycle from the Node server to a hydrated, interactive page.
Why server-side rendering
A standard Angular app ships a near-empty HTML shell and builds the DOM in the browser. That is fine for dashboards behind a login, but for content that needs to rank — articles, docs, marketing pages — the crawler often sees nothing useful. SSR renders the component tree to HTML on the server for the first request, so the page arrives fully populated. The browser then hydrates that markup: it attaches event listeners and reuses the existing DOM instead of throwing it away and rebuilding it.
| Concern | Client-only (CSR) | Server-side rendering (SSR) |
|---|---|---|
| First paint content | Empty shell, JS fills it | Full HTML from the server |
| SEO / crawlers | Often sees blank page | Sees real content immediately |
| Time to first byte | Fast HTML, slow content | Slightly slower, content included |
| Interactivity | After bundle loads | After hydration |
Project setup
The Angular CLI scaffolds SSR with a single flag. It adds a Node/Express server entry point, configures hydration, and updates the build targets.
ng new ng-blog --ssr --style=css
cd ng-blog
ng generate component pages/post-list --standalone
ng generate component pages/post-detail --standalone
The --ssr flag wires provideClientHydration() into app.config.ts. This is the single most important provider — it tells Angular to reuse server-rendered DOM instead of discarding it.
// src/app/app.config.ts
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()),
provideHttpClient(withFetch()),
],
};
Always pass
withFetch()toprovideHttpClient()in SSR apps. It uses the Fetch API, which Angular’sTransferStateunderstands, so HTTP responses fetched on the server are serialized into the HTML and not re-requested by the browser.withEventReplay()records clicks that happen before hydration finishes and replays them afterward.
A content service that works on the server
The same service runs on both the server and the browser. On the server there is no localStorage or window, so the data must come from somewhere universal — here, an HTTP endpoint. Because HttpClient is set up with withFetch() and hydration is enabled, the JSON loaded during server rendering is transferred to the client automatically.
// src/app/post.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface Post {
slug: string;
title: string;
excerpt: string;
body: string;
published: string;
}
@Injectable({ providedIn: 'root' })
export class PostService {
private readonly http = inject(HttpClient);
private readonly base = '/assets/posts';
list(): Observable<Post[]> {
return this.http.get<Post[]>(`${this.base}/index.json`);
}
bySlug(slug: string): Observable<Post> {
return this.http.get<Post>(`${this.base}/${slug}.json`);
}
}
Routing and rendering a post
Routes are plain data. The detail route reads its :slug param, fetches the post, and exposes it as a signal via toSignal() so the template can read it synchronously during rendering.
// src/app/app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
loadComponent: () =>
import('./pages/post-list/post-list.component').then((m) => m.PostListComponent),
},
{
path: 'post/:slug',
loadComponent: () =>
import('./pages/post-detail/post-detail.component').then((m) => m.PostDetailComponent),
},
];
// src/app/pages/post-detail/post-detail.component.ts
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { switchMap, tap } from 'rxjs';
import { Title, Meta } from '@angular/platform-browser';
import { PostService } from '../../post.service';
@Component({
selector: 'app-post-detail',
standalone: true,
templateUrl: './post-detail.component.html',
})
export class PostDetailComponent {
private readonly route = inject(ActivatedRoute);
private readonly posts = inject(PostService);
private readonly title = inject(Title);
private readonly meta = inject(Meta);
protected readonly post = toSignal(
this.route.paramMap.pipe(
switchMap((params) => this.posts.bySlug(params.get('slug')!)),
tap((post) => {
// Runs on the server too, so the served HTML has correct tags.
this.title.setTitle(post.title);
this.meta.updateTag({ name: 'description', content: post.excerpt });
this.meta.updateTag({ property: 'og:title', content: post.title });
}),
),
);
}
The template uses the new control flow. The @defer block is hydration-friendly: comments load lazily without blocking the article’s server-rendered content.
<!-- src/app/pages/post-detail/post-detail.component.html -->
@if (post(); as p) {
<article>
<h1>{{ p.title }}</h1>
<time>{{ p.published }}</time>
<div [innerHTML]="p.body"></div>
</article>
@defer (on viewport) {
<app-comments [slug]="p.slug" />
} @placeholder {
<p>Loading comments…</p>
}
} @else {
<p>Loading article…</p>
}
Running and serving SSR
Build with the SSR target, then run the generated Node server.
ng build
node dist/ng-blog/server/server.mjs
Output:
Node Express server listening on http://localhost:4000
Open a post and view source: the article HTML, <title>, and <meta> tags are all present before any JavaScript executes. Open DevTools and you will see Angular log that hydration matched the existing DOM.
Angular hydrated 1 component(s) and 42 node(s). 0 component(s) were skipped.
Best Practices
- Keep
provideClientHydration()enabled and avoid direct DOM manipulation (document,window) in components — it breaks hydration matching; guard such code withisPlatformBrowser()or move it intoafterNextRender(). - Use
provideHttpClient(withFetch())so server-fetched data transfers to the client viaTransferStateinstead of being requested twice. - Set
TitleandMetatags inside the data stream (as in thetapabove) so the server-rendered HTML already contains correct SEO tags for crawlers. - Defer non-critical, interactive widgets (comments, related posts) with
@deferso they never delay the article’s first paint. - Sanitize any HTML you bind with
[innerHTML]; Angular sanitizes by default, but only mark content trusted withDomSanitizerwhen you fully control the source. - Test by viewing page source (not the Elements panel) — the raw HTML is exactly what a search engine receives.