Encited logo
PricingTalk to sales
Angular SEO: How to Make an Angular App SEO Friendly, With or Without SSR

Angular SEO: How to Make an Angular App SEO Friendly, With or Without SSR

Oct 16, 2026October 16, 2026·Updated Oct 29, 2026October 29, 2026·by Aki from Encited

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/ssr replaced Angular Universal in Angular 17. Since Angular 19 you pick server rendering, prerendering or client rendering per route.
  • Even with SSR, @defer blocks without a hydrate trigger and RenderMode.Client routes reach crawlers empty.
  • Set titles with the route title, tags with Meta.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:

html
CopyDownload
<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.html title.

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 Meta service 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, which routerLink renders. A (click) on a button or a div gives 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

bash
CopyDownload
ng add @angular/ssr # an existing app
ng 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:

ts
CopyDownload
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. Call inject() before the first await, since Angular requires it to run synchronously there.
  • RenderMode.Client routes 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:

html
CopyDownload
@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:

ts
CopyDownload
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

ts
CopyDownload
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:

json
CopyDownload
{
"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 getPrerenderParams adds 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:

ts
CopyDownload
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.

ts
CopyDownload
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:

ts
CopyDownload
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:

html
CopyDownload
<!-- 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:

ts
CopyDownload
// src/main.ts
bootstrapApplication(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:

bash
CopyDownload
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:

  1. Check the head. In the same raw HTML, each route needs its own <title>, description, canonical and JSON-LD. If every route shows the index.html title, the tags are set in the browser only.
  2. Check deferred and late content. Search the raw HTML for a sentence from a @defer block, or from data the page loads after an API call.
  3. Compare crawlers. Run the route through the Googlebot simulator and the ChatGPT crawler simulator. URL Inspection in Search Console shows the HTML Googlebot rendered.
  4. Watch real visits. Your crawl log shows which bots fetched each route and what they got back.
  5. 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.

Google
Gemini
ChatGPT
Claude
Grok
Meta
Perplexity

Get discovered anywhere search happens

Readable, citable, outranking pages.

Talk to sales
Avatar
How can we help?
Get instant answers to your questions or leave a message for an engineer will reach out
Ask AI about Encited
See our docs
Contact support
Leave a message
We'll get back to you soon
Avatar
Ask AI about Encited
Product questions, troubleshooting and
Thinking
Preview
Drop an image to attach
Powered by ReplyMaven