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.
Quick answer
- Add server rendering:
ng new --ssrfor a new app,ng add @angular/ssrfor an existing one. It replaces the older Angular Universal packages. - Choose a render mode per route in
app.routes.server.ts:RenderMode.Prerenderfor pages that are the same for everyone,RenderMode.Serverfor pages with fresh data,RenderMode.Clientonly for pages nobody needs to find. - Set titles with the route's
titleproperty and descriptions with theMetaservice; both are rendered on the server. - Add a canonical per route through the
DOCUMENTtoken, since Angular has no built-in canonical helper. - Return a real
404from the not-found route with theRESPONSE_INITtoken. - Check a route with the free JavaScript SEO 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:
<body>
<app-root></app-root>
<script src="main-5HQ2K7RB.js" type="module"></script>
</body>
</html>The Angular documentation on server and hybrid rendering 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 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:
ng new my-shop --ssrFor an existing project:
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:
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 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:
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:
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:
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 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:
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 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 has the configurations.
Links, hydration and performance
- Links.
routerLinkon an<a>element renders a realhref. Do not navigate with(click)handlers ondivorbuttonelements 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@deferblocks, which hydrate parts of the page only when needed. - Deferred content. Content inside
@deferblocks is rendered on the server only when it uses hydrate triggers; a plain@deferrenders its placeholder on the server. Do not wrap the main text in a deferred block. - HTTP transfer cache.
HttpClientrequests 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
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:
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-engineon a current Angular version. - Using
RenderMode.Clientas the wildcard so every route not listed renders in the browser. addTagon 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
ngOnInitwithdocument.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.