# Angular SEO: server rendering, prerendering and route metadata

> Angular SEO starts with turning on server-side rendering or prerendering, because a default Angular app renders in the browser. How to set it up with @angular/ssr, choose a render mode per route, and set titles, meta tags, canonicals and status codes.

Updated 2026-09-26 · Technical SEO · HTML version: https://getreport.app/guides/angular-seo

Angular SEO comes down to one setting: whether your routes reach crawlers as rendered HTML or as an empty `<app-root></app-root>`. A default Angular app renders in the browser, so Google has to render it later and most AI crawlers and link previews see nothing. Adding server-side rendering or build-time prerendering with `@angular/ssr` fixes that, and the `Title` and `Meta` services then put per-route tags into the HTML. This guide covers setup in current Angular versions, choosing a render mode per route, head tags, canonicals and status codes. It is part of our wider guide to [JavaScript SEO for framework-built sites](https://getreport.app/guides/javascript-seo).

## Quick answer

- **Add server rendering:** `ng new --ssr` for a new app, `ng add @angular/ssr` for an existing one. It replaces the older Angular Universal packages.
- **Choose a render mode per route** in `app.routes.server.ts`: `RenderMode.Prerender` for pages that are the same for everyone, `RenderMode.Server` for pages with fresh data, `RenderMode.Client` only for pages nobody needs to find.
- **Set titles with the route's `title` property** and descriptions with the `Meta` service; both are rendered on the server.
- **Add a canonical per route** through the `DOCUMENT` token, since Angular has no built-in canonical helper.
- **Return a real `404`** from the not-found route with the `RESPONSE_INIT` token.
- Check a route with the free [JavaScript SEO check](https://getreport.app/tools/js-rendering-check) to see the raw HTML next to the rendered page.

## Why a default Angular app is hard to index

Without server rendering, every URL of an Angular app returns the same `index.html`:

```html
<body>
  <app-root></app-root>
  <script src="main-5HQ2K7RB.js" type="module"></script>
</body>
</html>
```

The Angular documentation on [server and hybrid rendering](https://angular.dev/guide/ssr) is plain about the consequence: client-side rendering "may negatively affect search engine optimization (SEO), as search crawlers have limits to how much JavaScript they execute when indexing a page". Google renders the page in a later step; the guide on [how Google renders JavaScript content](https://getreport.app/guides/javascript-rendered-content-and-google) explains the queue and what fails in it. AI crawlers such as GPTBot and ClaudeBot, and link previews in Slack, WhatsApp and LinkedIn, do not run the bundle at all.

The same documentation says server-side rendering and prerendering "generally" have "excellent search engine optimization (SEO), as search crawlers receive a fully rendered HTML document". That is the goal for every public route.

## Setting up server rendering

For a new project:

```bash
ng new my-shop --ssr
```

For an existing project:

```bash
ng add @angular/ssr
```

`@angular/ssr` is the successor to Angular Universal (`@nguniversal/*`), which older tutorials still describe. The command adds a server entry point, a server config and a server routes file. By default, Angular then prerenders every route it can find at build time and generates a server for the rest. To build a fully static site without a Node.js server, set `"outputMode": "static"` in `angular.json`.

Your code now runs on the server too, so it must not touch browser-only APIs such as `window`, `document` or `localStorage` during the first render. Use `afterNextRender` for browser-only work. The Angular docs warn against rendering different content on the server and in the browser with `isPlatformBrowser` checks in templates, because the mismatch causes layout shifts during hydration.

## Choosing a render mode per route

Server routes are declared in `app.routes.server.ts`:

```ts
import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  { path: '', renderMode: RenderMode.Prerender },
  { path: 'about', renderMode: RenderMode.Prerender },
  {
    path: 'products/:slug',
    renderMode: RenderMode.Prerender,
    async getPrerenderParams() {
      const slugs = await fetchProductSlugs();
      return slugs.map((slug) => ({ slug }));
    },
  },
  { path: 'account/**', renderMode: RenderMode.Client },
  { path: '**', renderMode: RenderMode.Server },
];
```

| Mode | When the HTML is built | Use it for | SEO |
| --- | --- | --- | --- |
| `RenderMode.Prerender` | At build time | Home, landing, category and product pages that change with deploys | Complete HTML, fastest |
| `RenderMode.Server` | Per request | Pages with prices, stock or content that changes between deploys | Complete HTML |
| `RenderMode.Client` | In the browser | Account areas, dashboards, checkout | Empty shell |

For prerendered routes with parameters, `getPrerenderParams` returns the list of values to build, and the `fallback` option decides what happens to paths that were not prerendered: server rendering by default, client rendering with `PrerenderFallback.Client`, or nothing with `PrerenderFallback.None`. Keep the default server fallback for public routes, so a product added after the build still reaches crawlers as HTML.

Our comparison of [server-side and client-side rendering](https://getreport.app/guides/ssr-vs-csr-seo) explains the trade-offs between the modes in more depth.

## Titles and meta descriptions per route

**Titles.** Give every route a `title` in the router config. Angular's router sets the document title on navigation, and on the server it ends up in the HTML:

```ts
export const routes: Routes = [
  { path: '', component: HomeComponent, title: 'Handmade shoes from Zagreb | Example Shop' },
  { path: 'about', component: AboutComponent, title: 'About us | Example Shop' },
  {
    path: 'products/:slug',
    component: ProductComponent,
    title: productTitleResolver,
  },
];
```

A resolver function can load the product and return its name. For a site-wide pattern such as "Page | Brand", extend `TitleStrategy` and provide it once.

**Descriptions and other meta tags.** Use the `Meta` service from `@angular/platform-browser` in the component, with data it already has:

```ts
import { Component, inject, input, effect } from '@angular/core';
import { Meta } from '@angular/platform-browser';

@Component({ selector: 'app-product', templateUrl: './product.html' })
export class ProductComponent {
  private meta = inject(Meta);
  product = input.required<Product>();

  constructor() {
    effect(() => {
      const p = this.product();
      this.meta.updateTag({ name: 'description', content: p.summary });
      this.meta.updateTag({ property: 'og:title', content: p.name });
      this.meta.updateTag({ property: 'og:image', content: p.imageUrl });
    });
  }
}
```

Use `updateTag` rather than `addTag`, so navigating between routes replaces the tag instead of adding a second one.

## Canonical tags in Angular

Angular has no canonical helper, so write a small service that updates one `<link rel="canonical">` through the `DOCUMENT` token. It works on the server and in the browser:

```ts
import { Injectable, inject, DOCUMENT } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class CanonicalService {
  private doc = inject(DOCUMENT);

  set(url: string) {
    let link = this.doc.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
    if (!link) {
      link = this.doc.createElement('link');
      link.setAttribute('rel', 'canonical');
      this.doc.head.appendChild(link);
    }
    link.setAttribute('href', url);
  }
}
```

Call it from each routed component with the clean, absolute URL: `https://example-shop.hr/products/blue-shoe`, without query parameters. On older Angular versions `DOCUMENT` is imported from `@angular/common`. Our guide to [canonical tags](https://getreport.app/guides/canonical-tags-explained) covers which URL to choose.

Keep `noindex` decisions in the server-rendered HTML too. A robots tag added or removed by the browser after load sends Google two different instructions.

## Status codes and redirects

**Not found.** With server rendering, a wildcard route that shows a "page not found" component still answers `200` unless you change the status. The `RESPONSE_INIT` token from `@angular/core` gives access to the response while rendering on the server:

```ts
import { Component, inject, RESPONSE_INIT } from '@angular/core';

@Component({ selector: 'app-not-found', template: '<h1>Page not found</h1>' })
export class NotFoundComponent {
  constructor() {
    const response = inject(RESPONSE_INIT, { optional: true });
    if (response) response.status = 404;
  }
}
```

The token is `null` in the browser and during prerendering, hence `optional: true`. Use the same approach in a product component when the product does not exist. For client-rendered routes, which cannot change the status, Google's advice for single-page apps applies: add `<meta name="robots" content="noindex">` to the error view. The guide to [SEO for single-page applications](https://getreport.app/guides/single-page-application-seo) covers both workarounds.

**Redirects.** A `redirectTo` in the route config becomes a real HTTP redirect during server rendering. In prerendered output it becomes a `<meta http-equiv="refresh">` tag, which Google follows but treats as a weaker signal. For permanent moves, prefer a `301` at the web server or CDN; our [301 redirects guide](https://getreport.app/guides/301-redirects) has the configurations.

## Links, hydration and performance

- **Links.** `routerLink` on an `<a>` element renders a real `href`. Do not navigate with `(click)` handlers on `div` or `button` elements for anything a crawler should follow.
- **Hydration.** `provideClientHydration()` reuses the server-rendered DOM instead of rebuilding it, which avoids a flash and layout shifts. Recent Angular versions add incremental hydration with `@defer` blocks, which hydrate parts of the page only when needed.
- **Deferred content.** Content inside `@defer` blocks is rendered on the server only when it uses hydrate triggers; a plain `@defer` renders its placeholder on the server. Do not wrap the main text in a deferred block.
- **HTTP transfer cache.** `HttpClient` requests made during server rendering are cached and reused during hydration, so the browser does not fetch the same data twice.

## How to check an Angular site

> **Free tool:** [JavaScript rendering checker: raw vs rendered HTML](https://getreport.app/tools/js-rendering-check): Free JavaScript rendering checker: compare raw HTML with the rendered page and see which text, links, tags and structured data appear only after scripts run.

The check fetches the route as raw HTML, then renders it in Chromium, and compares title, description, canonical, robots, headings, links, word count and JSON-LD field by field. Without server rendering, the raw column shows only `<app-root>` and a generic title. With it, both columns should match. A title or description that differs between them means a tag is set in the browser after load:

> **Check: JavaScript leaves the title, canonical and robots tags alone.** Search engines read the HTML first and the rendered page later, if at all. A canonical or noindex that JavaScript changes sends two different instructions, and Google may act on either.
>
> 1. Put the final title, description, canonical and robots tags in the HTML the server sends.
> 2. In a single-page app, set them on the server for each route (Next.js metadata, Nuxt useHead with SSR, Angular Universal).
> 3. Never add noindex with JavaScript to a page you want indexed; Google may drop it before the script runs.

> **Check: The visible text is present without JavaScript.** Google renders JavaScript later and with a budget, so text that only appears after scripts run can be indexed late or not at all. Other search engines and link previews may never see it.
>
> 1. Serve the main content in the HTML (server-side rendering or static generation) and use JavaScript only to enhance it.
> 2. Check the difference in the technical detail; menus and widgets are fine, headlines and body copy are not.

Also request a URL that does not exist with `curl -I https://example.com/does-not-exist` and confirm the status is `404`. In Google Search Console, URL Inspection → Test live URL shows the HTML Googlebot rendered.

## Common mistakes

- **Following old Angular Universal tutorials** with `@nguniversal/express-engine` on a current Angular version.
- **Using `RenderMode.Client` as the wildcard** so every route not listed renders in the browser.
- **`addTag` on every navigation,** which piles up duplicate description tags.
- **Browser-only code in constructors** that crashes server rendering, after which the host falls back to serving the client shell.
- **Titles set in `ngOnInit` with `document.title`,** which does not work on the server.
- **Serving the not-found component with `200`,** which turns every mistyped URL into an indexable empty page.

## Questions people ask

### Is Angular good for SEO?

Yes, with server rendering or prerendering turned on. A default Angular app renders in the browser, which delays indexing in Google and hides content from AI crawlers and link previews. With `@angular/ssr`, each route reaches crawlers as complete HTML, and the router's `title` and the `Meta` service put the head tags in that HTML. The render mode per route matters more than the framework.

### Is Angular Universal still needed for SEO?

No. Angular Universal has been replaced by the `@angular/ssr` package, which is part of the Angular CLI. Create a new project with `ng new --ssr` or add it to an existing one with `ng add @angular/ssr`. It adds server rendering, build-time prerendering and per-route render modes. Tutorials that install `@nguniversal` packages describe the older setup.

### How do I add meta tags to each Angular route?

Set the title with the route's `title` property or a `TitleStrategy`, and the description and Open Graph tags with the `Meta` service's `updateTag` in the routed component. Add the canonical through a small service that edits one `<link rel="canonical">` via the `DOCUMENT` token. With server rendering on, all of these are in the HTML crawlers receive.
