Skip to content
Angular projects 5 min read

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.

ConcernClient-only (CSR)Server-side rendering (SSR)
First paint contentEmpty shell, JS fills itFull HTML from the server
SEO / crawlersOften sees blank pageSees real content immediately
Time to first byteFast HTML, slow contentSlightly slower, content included
InteractivityAfter bundle loadsAfter 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() to provideHttpClient() in SSR apps. It uses the Fetch API, which Angular’s TransferState understands, 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 with isPlatformBrowser() or move it into afterNextRender().
  • Use provideHttpClient(withFetch()) so server-fetched data transfers to the client via TransferState instead of being requested twice.
  • Set Title and Meta tags inside the data stream (as in the tap above) so the server-rendered HTML already contains correct SEO tags for crawlers.
  • Defer non-critical, interactive widgets (comments, related posts) with @defer so they never delay the article’s first paint.
  • Sanitize any HTML you bind with [innerHTML]; Angular sanitizes by default, but only mark content trusted with DomSanitizer when you fully control the source.
  • Test by viewing page source (not the Elements panel) — the raw HTML is exactly what a search engine receives.
Last updated June 14, 2026
Was this helpful?