Does Angular SSR help SEO? Yes. Angular SSR SEO leverages server-side rendering to send crawlers complete HTML on the first response, including the title, description, content, and links, so they don't have to wait for JavaScript to build the page. It doesn't guarantee rankings. You still need per-route metadata, correct status codes, structured data, and a hydration setup that keeps the server-rendered content intact. Implementing Angular Universal SEO effectively can enhance your site's search engine visibility and improve Core Web Vitals performance.
This guide covers the whole path: deciding what to render on the server, setting it up, configuring each route, handling metadata and structured data, avoiding hydration problems, and checking what Google actually receives.
Why client-side Angular causes SEO problems
Angular renders in the browser by default. The server sends a nearly empty index.html plus script bundles, and the content appears after the JavaScript runs. Angular's own documentation says that search crawlers have limits on how much JavaScript they execute, so client-side rendering may hurt SEO. Its rendering strategies guide rates client-side rendering as poor for SEO and both SSR and prerendering as excellent.
Google can run JavaScript, so a client-rendered Angular app is not invisible. The cost is indirect. Google's JavaScript SEO basics describe three phases: crawling, rendering, and indexing. Pages are queued for rendering after the crawl, and the documentation notes that crawling works well for server-side rendered pages where the HTML response already contains all the content. Rendering also takes more resources than parsing plain HTML, making Angular Universal SEO an important consideration.
The problems you are most likely to notice:
Every page has the same title and description because they come from index.html.
Content or links exist only after JavaScript runs, so any crawler that doesn't render JavaScript sees an empty shell.
Social platforms show blank or generic link previews, because their scrapers generally read the raw HTML.
Search Console reports pages as discovered but not indexed, which can happen when the initial HTML is thin.
SSR fixes these at the source by putting the content in the first response.

Angular SSR, Prerendering or Client Rendering?
Angular supports three rendering modes and lets you choose per route, including Angular prerender options, which are crucial for optimizing Angular SSR SEO.
Angular's client-side rendering strategy is rated poorly for SEO because it relies on JavaScript execution, which search crawlers may not fully support, leading to incomplete indexing.
Mode | HTML is generated | SEO | Best for |
Prerender (SSG) | At build time | Excellent | Pages that are the same for every user: marketing pages, docs, most blog posts |
Server (SSR) | On each request | Excellent | Pages that depend on fresh or request-specific data |
Client (CSR) | In the browser | Poor | Pages that should not be indexed: dashboards, account pages |
Applied to a typical site:
Route type | Suggested mode | Reason |
Home, pricing, landing pages | Prerender | Fastest response, no per-request server work |
Blog posts and docs | Prerender | Stable content, rebuild on publish |
Product pages with changing data | Server | First response must reflect current data |
Logged-in dashboard | Client | No SEO value, depends on the user |
Prerendering has costs. It requires that everything needed to render a page is available at build time, and it can't include data specific to the user loading the page. Angular's docs also warn that generating a large number of pages with getPrerenderParams can lengthen builds and enlarge deployments. SSR has its own cost: your server runs Angular for every request, which can raise hosting costs.
Do you still need Angular Universal? No. Its functionality is now the @angular/ssr package, added to a project with ng add @angular/ssr. Tutorials that use @nguniversal/express-engine or build:ssr scripts describe the old setup.
What about dynamic rendering? That is the practice of serving bots a prerendered copy while users get the client app. Google's documentation on it calls it a workaround and recommends server-side rendering, static rendering, or hydration instead. Use a prerender service only for an app you can't move to SSR.
SSR, or Server-Side Rendering, generates HTML on each server request, ensuring that the content is readily available for search engines, significantly enhancing SEO for dynamic pages.
How to Set Up Angular SSR
For a new project:
ng new my-app --ssr
For an existing project:
ng add @angular/ssr
The schematic adds a server entry point (server.ts, which uses Express), main.server.ts, app.config.server.ts, and the SSR configuration in angular.json. This setup is crucial for Angular SSR SEO, ensuring your application is optimized for search engines.
Two details trip people up right away.
The default prerenders everything. Angular's docs state that by default it prerenders your entire application and generates a server file. To get true request-time SSR for a route, you must set that route's mode to RenderMode.Server. Setting outputMode to static in angular.json produces a fully static site with no server file, which suits hosting on a CDN.
HttpClient and fetch. In Angular 22, HttpClient uses the Fetch API by default, and withFetch() is deprecated. On Angular 21 and earlier, add provideHttpClient(withFetch()) so server-side requests use fetch.
// app.config.ts
import { provideHttpClient } from '@angular/common/http';
import { provideClientHydration } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(),
provideClientHydration(),
],
};
Before moving on, confirm SSR is active. Build the app, start the server, and request a page with curl. If the response contains your real content, it works. If it contains only an empty , that route is still client-rendered. The verification section below has the full set of checks.
If you are migrating from Universal, upgrade Angular first, remove the @nguniversal packages, and follow the output of the current ng add @angular/ssr rather than patching old configuration.

Configure Rendering Modes Per Route
Server routes live in their own file, app.routes.server.ts, so rendering choices stay separate from your navigation routes. Each entry is a ServerRoute with a RenderMode of Prerender, Server, or Client. This configuration is pivotal for optimizing Angular Universal SEO.
// app.routes.server.ts
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: 'pricing', renderMode: RenderMode.Prerender },
{
path: 'blog/:slug',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const posts = inject(PostService);
const slugs = await posts.getAllSlugs();
return slugs.map((slug) => ({ slug }));
},
},
{ path: 'products/:id', renderMode: RenderMode.Server },
{ path: 'account/**', renderMode: RenderMode.Client },
{ path: '**', renderMode: RenderMode.Server },
];
Then register the config on the server:
// app.config.server.ts
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);
A few rules from the docs matter here:
getPrerenderParams runs at build time and returns an array of parameter objects, one per page to generate. Pull the list from the same source your site uses, so the build never produces pages that don't exist.
Inside getPrerenderParams, call inject synchronously, before any await. The example above does this.
If a request arrives for a prerendered route's path that wasn't generated, Angular falls back to server rendering by default. You can change that with the fallback option (PrerenderFallback.Server, Client, or None).

Redirects and Status Codes
This is where SSR sites quietly lose SEO value.
With SSR, a redirectTo in your route config becomes a standard HTTP redirect (301 or 302).
With prerendering, the same redirect becomes a soft redirect using a tag, because there is no server at request time to send a status code. If you need true 301s for old URLs, handle them in your server or hosting configuration.
A not-found page that returns HTTP 200 is a soft 404. Google's JavaScript SEO documentation recommends avoiding soft 404s and notes that pages returning a 200 status are queued for rendering and indexing.
For a not-found route, set the status from inside the component using the RESPONSE_INIT token:
import { Component, inject, RESPONSE_INIT } from '@angular/core';
@Component({
selector: 'app-not-found',
template: `<h1>Page not found</h1>`,
})
export class NotFoundPage {
constructor() {
const responseInit = inject(RESPONSE_INIT, { optional: true });
if (responseInit) {
responseInit.status = 404;
}
}
}
Angular documents that RESPONSE_INIT is null when running in the browser, during the build, and during prerendering. So this only works for server-rendered requests. That is another reason the default fallback is useful: a request for a blog slug that wasn't prerendered falls back to SSR, where the component can set a real 404.
Set Metadata that Survives Server Rendering
Angular's Title and Meta services work on the server, but timing matters. Metadata must be set before the server serializes the page. If you set it only in a browser-side hook, crawlers get the generic values from index.html.
A small service keeps this consistent across routes:
// seo.service.ts
import { DOCUMENT, inject, Injectable } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
export interface SeoData {
title: string;
description: string;
url: string;
image?: string;
noindex?: boolean;
}
@Injectable({ providedIn: 'root' })
export class SeoService {
private title = inject(Title);
private meta = inject(Meta);
private doc = inject(DOCUMENT);
update(data: SeoData) {
this.title.setTitle(data.title);
this.meta.updateTag({ name: 'description', content: data.description });
this.meta.updateTag({
name: 'robots',
content: data.noindex ? 'noindex' : 'index, follow',
});
this.meta.updateTag({ property: 'og:title', content: data.title });
this.meta.updateTag({ property: 'og:description', content: data.description });
this.meta.updateTag({ property: 'og:url', content: data.url });
if (data.image) {
this.meta.updateTag({ property: 'og:image', content: data.image });
}
this.setCanonical(data.url);
}
private setCanonical(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);
}
}
In Angular 22, DOCUMENT is imported from @angular/core, which is also where the SSR guide imports it. On older versions, import it from @angular/common. Always access the document through this token rather than the global, because the global does not exist on the server.
Call the service once the page's data is available. A route resolver is the most predictable way to guarantee that:
// post.page.ts
export class PostPage {
private seo = inject(SeoService);
protected post: Post = inject(ActivatedRoute).snapshot.data['post'];
constructor() {
this.seo.update({
title: `this.post.title|ExampleSite`,description:this.post.summary,url:`https://example.com/blog/{this.post.slug}`,
image: this.post.coverImage,
});
}
}
During SSR, Angular waits for the application to stabilize before it renders the response, so data loaded through HttpClient or resolvers is normally ready when metadata is set. Asynchronous work Angular can't see, such as a bare setTimeout or a promise from a third-party library, may not finish in time. Register that work with Angular's PendingTasks service, or move it into a resolver.
One rule saves many headaches: never leave an indexable page on the default title and description from index.html.

Add Structured Data, Sitemaps, and Crawlable Links
JSON-LD that Lands in the Server HTML
Structured data should be present in the initial response, not injected after hydration. Create the script element through the DOCUMENT token so it works on the server. This method belongs in the same SeoService:
setJsonLd(data: object) {
let script = this.doc.getElementById('json-ld') as HTMLScriptElement | null;
if (!script) {
script = this.doc.createElement('script');
script.id = 'json-ld';
script.type = 'application/ld+json';
this.doc.head.appendChild(script);
}
// Escape "<" so content can never close the script tag early
script.textContent = JSON.stringify(data).replace(/</g, '\\u003c');
}
For an article, use Article or TechArticle with headline, datePublished, dateModified, and author. Add BreadcrumbList if your pages have breadcrumbs. Validate the result with Google's Rich Results Test. Structured data helps search engines interpret a page, but it doesn't force rich results, so don't add markup only to chase them.
Sitemap and robots.txt
Angular doesn't generate a sitemap for you. The simplest reliable approach is a build script that reads the same content source as getPrerenderParams and writes sitemap.xml into your public folder. Reference it from robots.txt. Generating both lists from one source prevents drift between what you prerender and what you submit.
Links Crawlers Can Follow
Search engines discover pages through elements. A routerLink on an anchor renders a real href, which is what you want. Navigation built from (click) handlers on buttons or divs gives crawlers nothing to follow, even with SSR on.

Hydration without Breaking SEO
After the server sends HTML, Angular hydrates it. That means it reuses the existing DOM and attaches event listeners instead of rebuilding the page. With hydration on, the server-rendered content stays in place. The ng add schematic enables it with provideClientHydration().
Four hydration problems affect SEO directly.
Content replaced by a loading state. If a component refetches its data in the browser and shows a spinner first, the server-rendered content disappears and returns. Angular's HttpClient helps here: on the server it caches outgoing requests and sends them to the browser inside the initial HTML, and the browser reuses them during the first render. A few details from the docs are worth knowing:
Only GET and HEAD requests are cached by default.
Requests carrying Authorization, Proxy-Authorization, or Cookie headers, or using credentials, are skipped.
Requests or responses with Cache-Control: no-store, no-cache, or private are skipped, as are responses with Set-Cookie.
The cache stops being used once the app becomes stable in the browser.
If your API sends no-store, the browser will refetch that data and you can see a flash. Adjust the headers or use withHttpTransferCacheOptions if the data is safe to reuse.
Browser-only code breaking the server. window, document, navigator, and location don't exist on the server. Run browser-specific work inside afterNextRender, which is skipped on the server:
constructor() {
afterNextRender(() => {
this.chart = createChart(this.canvas().nativeElement);
});
}
Angular's docs now recommend platform-specific providers over isPlatformBrowser checks. They specifically warn against using isPlatformBrowser in templates to render different content on the server and in the browser, because it causes hydration mismatches and layout shifts.
Deferred content missing from the server HTML. A plain @defer block renders its placeholder on the server, not its content. If important text lives inside one, crawlers get the placeholder. Incremental hydration fixes this. It is enabled by default with provideClientHydration(), and it includes event replay, so you don't need to add withEventReplay() separately. Add a hydrate trigger to the block:
@defer (hydrate on viewport) {
<app-reviews />
} @placeholder {
<div>Loading reviews</div>
}
With a hydrate trigger, the server renders the block's real content and the browser hydrates it later. Keep the @placeholder, because it is still used when the block renders during client-side navigation.
Hydration mismatches. These happen when server and browser output differ: invalid HTML nesting, direct DOM manipulation, or values like Date.now() and Math.random() rendered in a template. Angular logs a warning in development, so fix those before shipping.
Performance Trade-offs
SSR tends to help Largest Contentful Paint because the browser can paint real content without waiting for JavaScript. It can hurt Time to First Byte, because the server now does work before responding. Prerendered pages avoid that cost: they are static files, which CDNs and browsers cache easily.
If TTFB is a problem on SSR routes:
Prerender any route whose content doesn't change per request.
Cache rendered responses at a CDN or reverse proxy where the content allows it.
Cache slow API responses on the server.
Use NgOptimizedImage for the largest above-the-fold image.
Measure LCP, INP, and CLS on real pages before and after the change instead of assuming SSR improved them.
How to Verify Google Sees Your Content
This is the step that tells you whether any of the above worked.
1. Check the raw HTML. Request a page without running JavaScript and look at the response:
curl -s https://example.com/blog/my-post | grep -iE '<title>|name="description"|rel="canonical"|application/ld+json'
You should see the page's own title, description, canonical URL, and JSON-LD. Also confirm the body text and headings are in the response, not only the metadata.
2. Check status codes.
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/page-that-does-not-exist
curl -sI https://example.com/old-url
A missing page should return 404, and a permanently moved page should return 301 with a Location header. Prerendered redirects are soft redirects and won't show a 301.
3. Load the page with JavaScript disabled in Chrome DevTools. What remains is roughly what a crawler that doesn't render JavaScript sees.
4. Use Search Console's URL Inspection tool and run a live test. It shows the HTML Google rendered and is the authoritative check. Changing the user agent in curl only tests how your own server responds, not how Googlebot behaves.
5. Run the Rich Results Test on pages with structured data.
A page passes when its raw HTML contains a unique title, description, canonical, headings, body content, structured data, and the correct status code.
Common Angular SSR SEO Mistakes
Symptom | Likely cause | Fix |
Every page shows the same title | Metadata only in index.html | Set per-route values with Title and Meta |
Empty <app-root> in raw HTML | Route is client-rendered, or the SSR build isn't what's deployed | Check the route's RenderMode and the deployed output |
Dynamic pages are static and stale | Default prerendering was never switched to SSR | Set RenderMode.Server on routes that need fresh data |
Deleted pages stay indexed | Not-found page returns 200 | Set a 404 status with RESPONSE_INIT |
Content flashes, then reloads | Browser refetches data and replaces the DOM | Keep hydration on and check API cache headers |
Section missing from raw HTML | Content inside a plain @defer | Move it out, or add a hydrate trigger |
Server error on certain pages | window or localStorage used directly | Use afterNextRender or a platform-specific provider |
SSR request fails with NG02825 | Server-side API response over the default 1 MB body limit | Return less data, or raise maxResponseBodySize carefully |
New pages not discovered | Sitemap not regenerated | Build the sitemap from the same source as your prerender list |

Implementation Checklist
Classify routes as prerender, server, or client.
Add @angular/ssr and confirm the build serves real HTML.
Define serverRoutes, register them with withRoutes, and use getPrerenderParams for dynamic routes.
Switch routes that need fresh data from the default prerender to RenderMode.Server.
Return correct status codes and use the right redirect type.
Set a unique title, description, canonical, and Open Graph tags per route, before the response is serialized.
Add JSON-LD in the server HTML and generate a sitemap from your content source.
Use routerLink for all navigation.
Keep SEO-critical content out of plain @defer blocks, and move browser-only code into afterNextRender.
Verify with curl, JavaScript disabled, and Search Console URL Inspection.
Sources and further reading
Angular: Server and hybrid rendering
Angular: Rendering strategies
Angular: Hydration
Angular: Incremental hydration
Google Search Central: JavaScript SEO basics
Google Search Central: Dynamic rendering
Key Takeaways
Every page has the same title and description because they come from index.html.
Content or links exist only after JavaScript runs, so any crawler that doesn't render JavaScript sees an empty shell.
Social platforms show blank or generic link previews, because their scrapers generally read the raw HTML.
Search Console reports pages as discovered but not indexed, which can happen when the initial HTML is thin.
Inside getPrerenderParams, call inject synchronously, before any await. The example above does this.
Google preferred sources
Get the next teardown first
Make REO Rank one of your Google preferred sources. Our analysis rides higher in Top Stories and carries a "Preferred" badge in AI Mode and AI Overviews, so you actually see the next one.
- About 5 seconds
- Free, no account
- Undo any time
