Mert Özen Further With Every Line
SEO & GEO Technical SEO — #09

SEO with Angular SSR: Server Routes, RenderMode, Meta Tags and Hydration

Mert Özen Nov 8, 2026 11 min 5 views
SEO with Angular SSR: Server Routes, RenderMode, Meta Tags and Hydration

In Angular the render mode is chosen per route: a blog post uses Prerender with getPrerenderParams, search uses Server, the account page uses Client. We look at a service managing Title, Meta and canonical through DOCUMENT, REQUEST and RESPONSE_INIT, the HTTP transfer cache halving API calls, the isPlatformBrowser and useValue pitfalls, and allowedHosts rejecting even localhost with 400, with real outputs.

Version note: The code in this post was built and run with Angular 22.2 (@angular/core 22.2.5, @angular/ssr 22.2.6), Node.js 24 and Express 5; the APIs (Application Programming Interfaces) are explained based on angular.dev as of November 2026.

Guess First

After the measurement in the previous post, the Bookstore team decided to add SSR (Server-Side Rendering) to its Angular application: ng add @angular/ssr, build, deploy. Three things happen.

First, the developer who runs the server on localhost for the first time gets 400 on every page. Second, someone put a counter object into the server config with useValue to show "how many times it was rendered"; what does the page say on the third request? Third, the price is shown only in the browser with isPlatformBrowser in the template, because it's "more accurate in the browser"; does a crawler see the price in the first HTML (HyperText Markup Language)? All three answers are below, with real outputs.

The previous post measured that content to be indexed must be in the first HTML. This post shows, route by route, how that's set up in Angular: render modes, prerender parameters, Title/Meta/canonical, status and headers, hydration, and SSR's security setting.

What Comes with ng new --ssr?

ng new bookstore-ng --ssr (or ng add @angular/ssr in an existing project) brings four files: app.config.server.ts, which merges the browser and server configs; app.routes.server.ts, which defines the render mode per route; server.ts, an Express-based Node.js server; and the server-side entry point. The generated server config looks like this:

import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';

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

export const config = mergeApplicationConfig(appConfig, serverConfig);

The app.routes.server.ts generated by the template is a single line: { path: '**', renderMode: RenderMode.Prerender }. In other words, everything is attempted to be prerendered at build time. Enough for a small brochure site, but the wrong default for search, personal pages and content unknown at build time. The first job is to write this file according to the application's reality. On the browser side, provideClientHydration() comes with the template; I added provideHttpClient(withFetch()) for HTTP (HyperText Transfer Protocol) calls.

Render Mode per Route

Bookstore's server routes turn the table from the previous post into code:

import { RenderMode, ServerRoute } from '@angular/ssr';
import { BOOKS } from './books';

export const serverRoutes: ServerRoute[] = [
  { path: '', renderMode: RenderMode.Prerender },
  {
    path: 'books/:slug',
    renderMode: RenderMode.Prerender,
    async getPrerenderParams() {
      return BOOKS.map(b => ({ slug: b.slug }));
    },
    // slugs not known at build time are rendered on the server per request
  },
  { path: 'old-books/:slug', renderMode: RenderMode.Server, status: 301 },
  { path: 'search', renderMode: RenderMode.Server },
  { path: 'account', renderMode: RenderMode.Client, headers: { 'X-Robots-Tag': 'noindex' } },
  { path: '**', renderMode: RenderMode.Server, status: 404 },
];

The book detail is RenderMode.Prerender: the same for everyone and changes rarely. For a parameterized route, getPrerenderParams returns the values the build should generate pages for; in a real application this list comes from an API or database. The Angular documentation has a warning: inject calls in this function must be synchronous, meaning inject can't be called after an await. What happens when a slug not on the list arrives is decided by fallback: the default is PrerenderFallback.Server (render on the server at request time), the other options are Client and None. Bookstore uses the default; in the sixth post of the series we saw an unknown slug get a real 404 through this path.

The build output tells you what was prerendered:

$ npm run build
> ng build && node scripts/sitemap.mjs
Prerendered 4 static routes.
sitemap.xml: 4 URLs, 357 bytes

$ cat dist/bookstore-ng/prerendered-routes.json
{
  "routes": {
    "/": {},
    "/books/dune": {},
    "/books/foundation": {},
    "/books/neuromancer": {}
  }
}

Search is RenderMode.Server: every request has different results, and the results page must arrive in the first HTML. The account page is RenderMode.Client: personal, not to be indexed, no point rendering it on the server; it gets noindex via a header. Know the limit of prerendering too: HTML generated at build time doesn't change until the next build. Baking frequently changing information such as price or stock into a prerendered page means showing stale data to crawlers and users.

Title, Meta and Canonical

A single service manages the per-page SEO (Search Engine Optimization) tags. Title and Meta are Angular's own services; for the canonical, the DOCUMENT token is used, because there's no global document on the server. The same code runs during rendering on the server and during navigation in the browser:

import { DOCUMENT, Injectable, inject } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';

const SITE = 'https://bookstore.example';

@Injectable({ providedIn: 'root' })
export class SeoService {
  private readonly document = inject(DOCUMENT);
  private readonly title = inject(Title);
  private readonly meta = inject(Meta);

  update(page: { title: string; description: string; path: string; noindex?: boolean }): void {
    this.title.setTitle(`${page.title} | Bookstore`);
    this.meta.updateTag({ name: 'description', content: page.description });
    if (page.noindex) {
      // a noindex page should not point to a canonical URL
      this.meta.updateTag({ name: 'robots', content: 'noindex' });
      this.document.head.querySelector('link[rel="canonical"]')?.remove();
    } else {
      this.meta.removeTag('name="robots"');
      this.setCanonical(SITE + page.path);
    }
  }

  private setCanonical(url: string): void {
    // a single canonical link: update it if it exists, create it otherwise
    let link = this.document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
    if (!link) {
      link = this.document.createElement('link');
      link.setAttribute('rel', 'canonical');
      this.document.head.appendChild(link);
    }
    link.setAttribute('href', url);
  }
}

The canonical's host comes from a fixed value, not from the request's Host header. We saw why in the fifth post of the series on the ASP.NET Core side; it applies to Angular too. All three tags are in the first HTML of the prerendered book page:

<title>Dune | Bookstore</title>
<meta name="description" content="A desert planet, a noble family and the spice that everyone wants.">
<link rel="canonical" href="https://bookstore.example/books/dune">

In components, calling the service in the constructor or inside an effect is enough; for pages that depend on a route parameter, receiving the parameter as an input via withComponentInputBinding() keeps the tags updated even when the same component instance is reused with different slugs.

Status, Headers, REQUEST and RESPONSE_INIT

Static decisions (this route is always 404, this route always gets a noindex header) are made with the status and headers fields on the server route. For dynamic decisions made during rendering there are two tokens. RESPONSE_INIT is the Web API's ResponseInit object; a component can change status and headers during rendering. In the sixth post of the series we returned the 404 for an unknown book this way. REQUEST is the incoming request itself: URL (Uniform Resource Locator), headers, cookies. Both are populated only on the server while a request is being rendered; during the build, prerendering and in the browser they're null. That's why they should always be injected with { optional: true }:

// REQUEST is the incoming Web API Request on the server, null in the browser
protected readonly url = inject(REQUEST, { optional: true })?.url ?? 'browser';

A decision based on REQUEST never runs on a prerendered route, because there's no request during the build. Prerendering a page whose status varies per request therefore makes no sense; the 404 for an unknown book works because that request is rendered on the server via the fallback.

Hydration and the HTTP Transfer Cache

provideClientHydration() lets the browser take over the DOM (Document Object Model) from the server instead of throwing it away and redrawing it. Hydration matters for SEO in two ways: users and crawlers see the same HTML, and there's no layout shift on the first render. Part of hydration is the HTTP transfer cache: responses of HttpClient calls made during rendering on the server are embedded in the HTML, and the browser doesn't repeat the same request. At the end of the search page's HTML:

<script id="ng-state" type="application/json">{"a9acfa0700b782da1756dc530d49b0f7516777f20205f475cc5241e2c5d7ee4b":{"b":[{"slug":"dune","title":"Dune","author":"Frank Herbert","description":"A desert planet, a noble family and the spice that everyone wants."}],"h":{},"s":200,"st":"OK","u":"/api/search?q=dun","rt":"json"},...}</script>

To measure the effect, I added a line to the search API in Express that logs every request with its user agent, and opened the page with headless Chrome:

# default: provideClientHydration(withEventReplay())
api hit #1 from node

# provideClientHydration(withEventReplay(), withNoHttpTransferCache())
api hit #1 from node
api hit #2 from Mozilla/5.0 (Macinto

With the transfer cache on, only the server (Node.js's fetch) went to the API; the browser took the response from ng-state in the HTML. With it off, the browser made the same request a second time. By default, only GET and HEAD requests, and those carrying no authentication or cookie headers, are cached; that's a good default, because a personal response embedded in HTML and cached on a CDN (Content Delivery Network) means a data leak. There's also a size limit: the fetch backend on the server accepts 1 MB per response by default, and exceeding it raises error NG02825.

Two Server Pitfalls: isPlatformBrowser and useValue

The third opening question. The component that shows the price only in the browser:

@Component({
  selector: 'app-mismatch',
  template: `
    <h1>Dune</h1>
    @if (isBrowser) {
      <p class="price">Price: 12.99</p>
    }
  `,
})
export class Mismatch {
  // anti-pattern: the server and the browser render different DOM
  protected readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
}
$ curl -s http://localhost:20110/debug/mismatch | grep -o '<app-mismatch[^>]*>.*</app-mismatch>'
<app-mismatch ngh="0"><h1>Dune</h1><!----></app-mismatch>

# headless Chrome --dump-dom, after hydration
<app-mismatch ng-version="22.2.5"><h1>Dune</h1><p class="price">Price: 12.99</p><!----></app-mismatch>

The price isn't in the first HTML; it's added when JavaScript runs. In this example Angular created the @if block during hydration and I saw no error in the console; but the result is still unwanted: a crawler that runs no JavaScript never sees the price, the page Google renders differs from the first HTML, and the user gets a layout shift as the price pops in later. angular.dev says explicitly not to render different content in templates with isPlatformBrowser; for code that must run only in the browser (such as reading localStorage), afterNextRender is recommended. When the DOM structure genuinely doesn't match, Angular raises a hydration error (the NG0500 family) and re-renders that part from scratch.

The second opening question is about the server being a long-lived process. I defined two counters, one with useValue, the other with useFactory:

const shared = { count: 0 };

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(withRoutes(serverRoutes)),
    // the same object for every request until the process restarts
    { provide: SHARED_COUNTER, useValue: shared },
    // a new object for every request
    { provide: PER_REQUEST_COUNTER, useFactory: () => ({ count: 0 }) },
  ]
};
$ for i in 1 2 3; do curl -s http://localhost:20110/debug/visits | grep -o '<p>[^<]*</p>' | tr '\n' ' '; echo; done
<p>useValue: 1</p> <p>useFactory: 1</p> <p>url: http://localhost:20110/debug/visits</p>
<p>useValue: 2</p> <p>useFactory: 1</p> <p>url: http://localhost:20110/debug/visits</p>
<p>useValue: 3</p> <p>useFactory: 1</p> <p>url: http://localhost:20110/debug/visits</p>

Angular creates a new application instance for every request, but the object given to useValue is a single one; all requests share it. A counter is a harmless example; if the same object holds a user's language, cart or token, it leaks into the next user's page. There's an SEO risk too: if canonical or hreflang information that should vary per request is kept in a shared object, one page can be rendered with another page's canonical.

Security: allowedHosts

The first opening question. The first time I ran the server generated by ng new --ssr, every request returned 400:

Node Express server listening on http://localhost:20110
ERROR: Bad Request ("http://localhost:20110/").
Header "host" with value "localhost:20110" is not allowed.

For more information, see https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf

Angular SSR checks the Host header against a list of allowed hosts and returns 400 Bad Request for requests from hosts not on the list; in Angular 22.2, with that list empty, not even localhost was accepted. The reasoning is SSRF (Server-Side Request Forgery): during rendering the server resolves relative URLs against the request's host, and a forged Host header could make the server send requests to an unintended address on its own behalf. The list is given as a build option in angular.json:

"security": {
  "allowedHosts": ["bookstore.example", "localhost"]
}
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:20110/books/dune
200
$ curl -s -o /dev/null -w "%{http_code}\n" -H "Host: bookstore.example" http://localhost:20110/books/dune
200
$ curl -s -o /dev/null -w "%{http_code}\n" -H "Host: evil.example" http://localhost:20110/books/dune
400

The alternatives are passing an allowedHosts option to AngularNodeAppEngine, or the comma-separated NG_ALLOWED_HOSTS environment variable for the Node.js server. The documentation's warning: using * is a security risk; give an explicit list. The details of this setting, Host header validation, and secrets leaking into bundles through TransferState are in the Frontend Security series post "Angular SSR Security: allowedHosts, Host Header Validation, TransferState and Secrets Leaking into Bundles".

Production Notes

Check the prerender list on every deploy. If the number of routes in prerendered-routes.json is lower than expected (for example, the API called by getPrerenderParams returned empty during the build), pages silently fall back to server rendering; not an error, but the performance and cost profile changes. Asserting the route count in the build pipeline is a cheap check.

Verify the render mode in production: the ng-server-context attribute in a page's first HTML says ssg or ssr, and is absent on CSR (Client-Side Rendering) routes. If a configuration mistake drops a route that should be indexed into CSR, this attribute disappearing is the first sign.

Set up a deliberate cache strategy for SSR pages. Since every request is rendered, server load is higher than with prerendering; for pages like search, a short Cache-Control or a CDN cache in front helps. Never put personal responses into a shared cache.

mertozen.com is outside the scope of this post: the site isn't Angular, it's rendered on the server with ASP.NET Core MVC (Model-View-Controller); so the "content in the first HTML" property that hybrid rendering provides already exists on the site through Razor.

Common Pitfalls

Leaving the template's '**' → Prerender default as is. Search and personal pages get generated in a meaningless state at build time.

Showing different content in templates with isPlatformBrowser. The first HTML stays incomplete, the layout shifts, hydration may break.

Sharing state that should vary per request with useValue. As long as the server process lives, all requests see the same object.

Injecting REQUEST and RESPONSE_INIT without optional, or relying on them in prerendered routes. Both are null during the build.

Writing * into allowedHosts. The 400 error goes away, and so does the SSRF protection.

Embedding personal HTTP responses into the HTML through the transfer cache. The default settings prevent this; before turning on options such as includeRequestsWithAuthHeaders, make sure the page never enters a shared cache anywhere.

A Small Experiment

★☆☆ Look at dist/.../prerendered-routes.json in your own Angular SSR project and verify which mode each route in app.routes.server.ts is served with, by checking the ng-server-context attribute with curl -s.

★★☆ Change Bookstore's getPrerenderParams to read from an API instead of a fixed list; if the API returns an error during the build, make the build stop with a clear message. Compare silently prerendering zero routes with breaking the build.

★★★ Fix the Mismatch component: render the price on the server too, and apply only the browser-specific part (for example, the user's local currency preference from localStorage) inside afterNextRender. Verify with curl that the price is in the first HTML, and with headless Chrome that the DOM after hydration is consistent.

Bookstore has been single-language so far. In the next post we'll cover the URL strategy for Turkish and English versions, the three places for hreflang, x-default, and how hreflang and canonical must support each other, together with mertozen.com's move from the ?lang= structure to the /en/ subfolder.