Angular SEO: How to Make an Angular App SEO Friendly, With or Without SSR
Is Angular SEO friendly? What a client-rendered Angular app sends crawlers, and how to fix it with @angular/ssr, prerendering, meta tags or no SSR at all.
If ChatGPT can't quote your Angular pages, or every link you share shows the same title, check the HTML your server sends when a crawler asks for a route. A default Angular CLI build sends every route the same index.html: an empty <app-root> tag, a script, and the title from src/index.html. Googlebot fills the page in when it renders it later. AI crawlers and link previews keep the empty shell. Angular is SEO friendly once crawlers get rendered HTML, and you can make an Angular app SEO friendly in three ways: server-side rendering with @angular/ssr, build-time prerendering, or a pre-render layer in front of the app, which is how you do Angular SEO without SSR. The framework-neutral side is in JavaScript SEO, and the single-page app pitfalls that apply whatever you choose are in SPA SEO.
TL;DR
- A client-rendered Angular app sends crawlers
<app-root></app-root>and one title for every route. - Googlebot runs the scripts later. GPTBot, ClaudeBot, PerplexityBot and link previews don't run them.
@angular/ssrreplaced Angular Universal in Angular 17. Since Angular 19 you pick server rendering, prerendering or client rendering per route.- Even with SSR,
@deferblocks without ahydratetrigger andRenderMode.Clientroutes reach crawlers empty. - Set titles with the route
title, tags withMeta.updateTag(), and JSON-LD with a script in the head. - Without SSR, a pre-render layer serves crawlers a snapshot of each finished page and leaves your Angular code alone.
Is Angular SEO friendly?
Angular builds a client-rendered app unless you add server rendering. Angular's own docs say client-side rendering may hurt SEO "as search crawlers have limits to how much JavaScript they execute when indexing a page."
A crawler that asks for any route of a client-rendered app gets the same index.html. Apart from the script and stylesheet tags, its content is two lines:
<title>YourStore</title><app-root></app-root>
/, /products/blue-mug and /about all return it: one title, no description, no canonical, no links, no text. The titles and meta tags your components set appear only after main.js runs.
What happens next depends on the crawler:
- Googlebot queues the page and renders it later. Google's JavaScript SEO guide says the wait can be a few seconds or longer. Content from scripts or API calls that fail or time out is missing from what it indexes.
- AI crawlers such as GPTBot, ClaudeBot and PerplexityBot fetch the HTML and don't run the scripts, so they read the shell. Do AI crawlers render JavaScript goes through each one.
- Link previews in Slack, LinkedIn, X and Facebook read the raw HTML too, so every shared link shows the
index.htmltitle.
React and Vue apps have the same problem. React SEO and Vue SEO cover their fixes, and JavaScript SEO covers what else goes wrong on JavaScript sites.
Angular SEO issues in a client-rendered SPA
- One title for every route until JavaScript runs. Route titles and the
Metaservice change the page in the browser only. - Canonical tags added by JavaScript. Google's JavaScript SEO guide says the best place for the canonical is the HTML, and that JavaScript shouldn't change it to a different URL.
- Soft 404s. The
**route shows your not-found page, but the server answers 200. - Hash URLs.
withHashLocation()puts every route after a#, which Google reads as one URL. - Links built from click handlers. Crawlers follow
<a href>elements, whichrouterLinkrenders. A(click)on a button or adivgives them nothing to follow.
The last three have framework fixes in dynamic rendering in React, Vue and Angular, along with a table of which crawlers run JavaScript.
How to make Angular SEO friendly: SSR, prerendering or no SSR
Choose per route, by who needs to read it and how often it changes. SSR and prerendering run your components before the HTML leaves your server, so the first response holds the text, links, titles and meta tags. Angular's docs rate both as excellent for SEO. Client rendering costs the least to host.
| Option | What crawlers get | What you run | Code changes |
|---|---|---|---|
| Client rendering (default) | The <app-root> shell. Googlebot renders it later |
Any static host | None |
SSR with RenderMode.Server |
Full HTML on every request | A Node.js server, or another runtime that runs Angular | Browser-only code kept out of server paths |
Prerendering with RenderMode.Prerender |
Full HTML built at deploy time | A static host, or the SSR server | Same as SSR, plus the list of routes at build time |
| Encited (pre-render layer, no SSR) | A snapshot of the finished page, or Markdown for AI crawlers that ask | A DNS change or an edge snippet | None |
How that maps to a typical site:
- Product, listing and article pages whose data changes often: SSR, or a pre-render layer that refreshes those paths daily.
- Marketing pages, docs and a blog: prerendering.
- Account, checkout and dashboard routes: client rendering. Keep them out of your sitemap.
- An app built on browser-only libraries, a host with no Node.js runtime, or no time to refactor for hydration: a pre-render layer.
SSR vs SSG vs pre-rendering compares the three approaches outside Angular.
Angular SSR SEO: set up @angular/ssr and hydration
Add server rendering
ng add @angular/ssr # an existing appng new your-store --ssr # a new app
The schematic adds src/main.server.ts, an Express server in src/server.ts, app.config.server.ts, the server route config app.routes.server.ts, and provideClientHydration() in app.config.ts. One ng build then builds the browser bundle, the server bundle and the prerendered pages.
Choose a render mode per route
The generated app.routes.server.ts prerenders every route (path: '**' with RenderMode.Prerender). Most apps need a mix:
import { inject } from '@angular/core';import { RenderMode, ServerRoute } from '@angular/ssr';import { PostService } from './post.service';export const serverRoutes: ServerRoute[] = [{ path: '', renderMode: RenderMode.Prerender },{path: 'blog/:slug',renderMode: RenderMode.Prerender,async getPrerenderParams() {const posts = inject(PostService);const slugs = await posts.getSlugs();return slugs.map((slug) => ({ slug }));},},{ path: 'products/:id', renderMode: RenderMode.Server },{ path: 'account/**', renderMode: RenderMode.Client },{ path: '**', renderMode: RenderMode.Server },];
- A prerendered route with a parameter fails the build until it has
getPrerenderParams. Callinject()before the firstawait, since Angular requires it to run synchronously there. RenderMode.Clientroutes still send the empty shell, even from an SSR server.
Hydration
provideClientHydration() lets the browser reuse the server's HTML. Without it, Angular discards the server DOM and renders the page again, which Angular's docs say can cause a visible flicker and hurt Core Web Vitals. Add withI18nSupport() only if you use Angular's i18n; without it, Angular skips hydration for i18n blocks.
Hydration needs the same DOM on the server and in the browser. Components that edit their own DOM with innerHTML or appendChild, invalid HTML such as a <div> inside a <p>, and third-party scripts that change the page early cause mismatch errors. Angular DevTools highlights the component at fault, and ngSkipHydration on its host is the documented last resort.
Keep deferred content in the server HTML
On the server, a @defer block renders only its @placeholder, in SSR and prerendering alike. Reviews, specs or FAQs inside a plain @defer never reach the HTML crawlers read.
To see whether that happens on your site, run a product route through the Googlebot simulator and search the response for a sentence from the reviews.
Add a hydrate trigger and Angular renders the main content on the server, then hydrates it in the browser when the trigger fires:
@defer (hydrate on viewport) {<app-product-reviews [productId]="id()" />} @placeholder {<p>Customer reviews</p>}
hydrate triggers need incremental hydration, which provideClientHydration() turns on by default since Angular 22. On Angular 19 to 21, add withIncrementalHydration().
Browser-only code during server rendering
Angular's docs list window, document, navigator and location as unavailable on the server. Constructors and class hooks such as ngOnInit and ngOnChanges run during server rendering too, so code in them has to work on both sides. Angular skips only the render callbacks, afterNextRender and afterEveryRender, on the server. Put browser-only code there.
New apps have been zoneless by default since Angular 21. In a zoneless app, the server waits for router navigations and HttpClient requests before it sends the page. Data from fetch() or a client SDK such as Firebase's needs PendingTasks, or the server sends the page before the data arrives:
import { Component, PendingTasks, afterNextRender, inject, signal } from '@angular/core';@Component({selector: 'app-store-hours',template: `<ul>@for (line of hours(); track line) { <li>{{ line }}</li> }</ul>`,})export class StoreHours {private tasks = inject(PendingTasks);hours = signal<string[]>([]);constructor() {this.tasks.run(async () => {const res = await fetch('https://api.yourstore.com/hours');this.hours.set(await res.json());});afterNextRender(() => {const preferred = localStorage.getItem('preferredStore');// browser only: personalize after the page is on screen});}}
Don't branch a template on isPlatformBrowser to show different content on the server. Angular's docs warn it causes hydration mismatches and layout shifts.
Return a real 404
import { Component, RESPONSE_INIT, inject } from '@angular/core';@Component({selector: 'app-not-found',template: `<h1>Page not found</h1>`,})export class NotFound {constructor() {const response = inject(RESPONSE_INIT);if (response) {response.status = 404;}}}
Put { path: '**', component: NotFound } last in your routes and keep ** on RenderMode.Server. RESPONSE_INIT is null during prerendering and in the browser, so only a server-rendered route can set the status. Redirects from redirectTo go out as real HTTP redirects from SSR.
Angular Universal SEO: moving to @angular/ssr
Angular Universal served Angular 16 and earlier, with ng add @nguniversal/express-engine and scripts such as npm run build:ssr and serve:ssr. In Angular 17 it moved into the Angular CLI as @angular/ssr, and ng update to 17 included a migration from @nguniversal. Angular Universal SEO tutorials that use those commands target old versions. On Angular 17 or later, use @angular/ssr.
Angular 22 deprecated CommonEngine in favor of AngularNodeAppEngine. A server.ts that still imports CommonEngine predates the current server API.
Build-time prerendering (SSG) in the Angular CLI
RenderMode.Prerender routes become HTML files at build time. Crawlers get the fastest response, and nothing runs per request. It fits pages that look the same for every visitor and change when you deploy.
For a site with no server at all, set outputMode to static in angular.json:
{"projects": {"your-store": {"architect": {"build": {"options": {"outputMode": "static"}}}}}}
Angular then writes one HTML file per prerendered route and no server bundle. Deploy dist/your-store/browser to Firebase Hosting, Netlify, Cloudflare Pages or S3. After each build, dist/your-store/prerendered-routes.json lists every route that got a file, so you can confirm new pages made it in.
Limits:
- Data is fixed at build time. A price or stock change waits for the next deploy.
- Angular's docs warn that prerendering many pages through
getPrerenderParamsadds build time and deploy size. - Redirects on prerendered routes become
<meta http-equiv="refresh">tags. Put permanent redirects in your host's config so crawlers get a 301.
Angular SEO meta tags: titles, descriptions, canonicals and JSON-LD
With SSR, prerendering or a pre-render layer, your Angular SEO meta tags are in the HTML every crawler reads. In a client-rendered app, only Googlebot sees them, after rendering.
Page titles
Set a title on each route, or a resolver when the title comes from data:
import { inject } from '@angular/core';import { ResolveFn, Routes } from '@angular/router';import { ProductService } from './product.service';const productTitle: ResolveFn<string> = (route) =>inject(ProductService).getName(route.paramMap.get('id')!);export const routes: Routes = [{ path: '', component: Home, title: 'Handmade mugs and bowls | YourStore' },{ path: 'products/:id', component: ProductPage, title: productTitle },];
To add your site name to every title in one place, provide a custom TitleStrategy.
Description, Open Graph, canonical and JSON-LD
Meta.updateTag() updates a tag, or creates it when it's missing, so running it on the server and again in the browser leaves one tag. Canonical links have no Angular service, and Angular doesn't support <script> elements in templates, so both go in through the DOCUMENT token. Angular's SSR guide uses the same approach for canonicals.
import { DOCUMENT, Injectable, inject } from '@angular/core';import { Meta } from '@angular/platform-browser';@Injectable({ providedIn: 'root' })export class Seo {private meta = inject(Meta);private doc = inject(DOCUMENT);setPage(page: { title: string; description: string; url: string; image: string }) {this.meta.updateTag({ name: 'description', content: page.description });this.meta.updateTag({ property: 'og:title', content: page.title });this.meta.updateTag({ property: 'og:description', content: page.description });this.meta.updateTag({ property: 'og:url', content: page.url });this.meta.updateTag({ property: 'og:image', content: page.image });this.headElement('link', 'canonical-link', { rel: 'canonical', href: page.url });}setJsonLd(data: object) {const script = this.headElement('script', 'page-jsonld', { type: 'application/ld+json' });script.textContent = JSON.stringify(data).replace(/</g, '\\u003c');}private headElement(tag: string, id: string, attrs: Record<string, string>) {let el = this.doc.getElementById(id);if (!el) {el = this.doc.createElement(tag);el.id = id;this.doc.head.appendChild(el);}for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);return el;}}
The fixed id lets the browser run find the server's tag and update it, so the page never ends up with two canonicals or two JSON-LD blocks. On Angular 19, import DOCUMENT from @angular/common.
Call it when the page's data arrives:
this.products.get(id).subscribe((p) => {this.seo.setPage({title: p.name,description: p.summary,url: `https://yourstore.com/products/${p.id}`,image: p.imageUrl,});this.seo.setJsonLd({'@context': 'https://schema.org','@type': 'Product',name: p.name,image: p.imageUrl,offers: { '@type': 'Offer', price: p.price, priceCurrency: 'USD', availability: 'https://schema.org/InStock' },});});
Google's structured data docs say Product markup generated with JavaScript can make Shopping crawls less frequent and less reliable. On a store, get JSON-LD into the HTML the server sends.
Angular SEO without SSR
Some apps stay client-rendered: they depend on browser-only libraries such as maps, charts and editors, their host has no Node.js runtime, or the team can't refactor for hydration yet. Smaller bundles, lazy routes with loadComponent and compressed images help visitors and Core Web Vitals. Crawlers that don't run JavaScript still get the shell.
A pre-render layer takes care of crawlers. It sits at your DNS, CDN or host edge and checks each request's user agent. Crawlers get a snapshot of the page, saved from a headless browser after Angular finished rendering. People get your app as before. Google calls this approach dynamic rendering, and dynamic rendering SEO covers how Google treats it.
With Encited in front of your Angular app, each route reaches search engines, link previews and AI crawlers as finished HTML, and AI crawlers that ask for Markdown get Markdown. You set refresh rules per path, such as daily for products and monthly for the about page, and broken pages are never cached or served.
Setup is a DNS change or a snippet for your host: Netlify, Vercel, Cloudflare Workers or Express, which also fits the server.ts that @angular/ssr generates. Plans start at $19 a month.
Encited waits for the network and the page to go quiet before it saves a snapshot. For routes with slow or chained API calls, set window.prerenderReady = false in src/index.html, so it's in place before Angular starts:
<!-- src/index.html, inside <head> --><script>window.prerenderReady = false;</script>
Then set it to true once Angular is done. Encited waits up to 30 seconds for true before it saves the snapshot. ApplicationRef.whenStable() resolves when Angular has no pending work, which includes router navigations and HttpClient requests:
// src/main.tsbootstrapApplication(App, appConfig).then((appRef) =>appRef.whenStable().then(() => Object.assign(window, { prerenderReady: true })),);
For unknown routes, have the not-found component set <meta name="prerender-status-code" content="404">, and crawlers get a real 404. Real 404s in Angular shows where it goes.
A pre-render layer also works on an SSR app, for the parts SSR leaves out: @defer blocks without hydrate triggers and RenderMode.Client routes.
How to check what Google and AI crawlers get
On an SSR or prerendered app, the root tag in the raw HTML records how Angular rendered the route:
curl -s https://yourstore.com/products/blue-mug | grep -o 'ng-server-context="[a-z]*"'
ssr means server-rendered and ssg means prerendered. No match means the route went out as the client shell. Then:
- Check the head. In the same raw HTML, each route needs its own
<title>, description, canonical and JSON-LD. If every route shows theindex.htmltitle, the tags are set in the browser only. - Check deferred and late content. Search the raw HTML for a sentence from a
@deferblock, or from data the page loads after an API call. - Compare crawlers. Run the route through the Googlebot simulator and the ChatGPT crawler simulator. URL Inspection in Search Console shows the HTML Googlebot rendered.
- Watch real visits. Your crawl log shows which bots fetched each route and what they got back.
- Audit every route. One missing title resolver leaves a whole section on the default title. A site audit finds missing titles, duplicate descriptions and canonical problems across the site.
For a single route, the free SEO audit runs the head checks from step 1 for you: title, description, canonical, schema and Open Graph.
Angular 19 SEO and what changed through Angular 22
| Version | Released | What changed for SEO |
|---|---|---|
| 17 | November 2023 | Angular Universal moved into the CLI as @angular/ssr. ng new --ssr added |
| 19 | November 2024 | Per-route render modes and getPrerenderParams in developer preview, the outputMode option, incremental hydration in developer preview |
| 20 | May 2025 | Server routing and incremental hydration stable. Routes registered with provideServerRendering(withRoutes(...)) |
| 21 | November 2025 | New apps zoneless by default |
| 22 | June 2026 | provideClientHydration() turns on incremental hydration. CommonEngine deprecated |
Angular 19 SEO works the same way, with three differences: the server routing API is a developer preview, registered with provideServerRouting(serverRoutes) on 19.2 and provideServerRoutesConfig(serverRoutes) on 19.0 and 19.1; DOCUMENT comes from @angular/common; and hydrate triggers need withIncrementalHydration(). Angular's release page lists versions 2 through 19 as no longer supported, so an update to 20 or later also brings stable server routing and security fixes.
FAQ
Is Angular SEO friendly?
Yes, once crawlers get rendered HTML. A default client-rendered build sends every route an empty <app-root>, which Googlebot renders later and AI crawlers and link previews never render. @angular/ssr, build-time prerendering or a pre-render layer puts the content in the first response.
Can you do Angular SEO without SSR?
Yes. Prerender routes that are the same for every visitor with outputMode: "static", which needs server-compatible code but no server. Or put a pre-render layer in front of the client-rendered app, which needs neither.
Which is better for SEO, Angular SSR or CSR? SSR. Crawlers get the full page in the first response and don't depend on running your JavaScript. CSR suits pages behind a login that you don't want indexed, and Angular 19 and later let you mix both per route.
What is the difference between Angular Universal and @angular/ssr?
@angular/ssr is Angular Universal's successor. Universal (@nguniversal/*) served Angular 16 and earlier. In Angular 17 it moved into the Angular CLI as @angular/ssr, set up with ng add @angular/ssr or ng new --ssr.
How do I add meta tags in Angular?
Use the route title for titles and Meta.updateTag() from @angular/platform-browser for descriptions and Open Graph tags. Add canonical links and JSON-LD through the DOCUMENT token, with fixed ids so the server and browser runs don't duplicate them.
Do @defer blocks hurt Angular SEO?
They can. During SSR and prerendering, a @defer block renders only its placeholder, so its content isn't in the HTML. A hydrate trigger such as hydrate on viewport renders it on the server.
Is Angular end of life? No. Angular 22 came out on June 3, 2026, with active support until June 2027 and long-term support until June 2028, according to Angular's release page. Versions 2 through 19 are no longer supported.
