Skip to main content
Technical Guide

Angular SSR for SEO: A Complete Implementation Guide

This guide covers Angular SSR from planning server rendering to setup, route configuration, metadata, structured data, hydration, and verifying what Google receives.

Angular SSR for SEO: A Complete Implementation Guide

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

  1. Classify routes as prerender, server, or client.

  2. Add @angular/ssr and confirm the build serves real HTML.

  3. Define serverRoutes, register them with withRoutes, and use getPrerenderParams for dynamic routes.

  4. Switch routes that need fresh data from the default prerender to RenderMode.Server.

  5. Return correct status codes and use the right redirect type.

  6. Set a unique title, description, canonical, and Open Graph tags per route, before the response is serialized.

  7. Add JSON-LD in the server HTML and generate a sitemap from your content source.

  8. Use routerLink for all navigation.

  9. Keep SEO-critical content out of plain @defer blocks, and move browser-only code into afterNextRender.

  10. 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.

Add REO Rank as a preferred source
  • About 5 seconds
  • Free, no account
  • Undo any time

FAQs

Questions on this topic

Answers to what readers ask about this topic.

Is Angular bad for SEO?
No. A default client-rendered Angular app is weak for SEO, but Angular ships first-party SSR and prerendering that give crawlers full HTML. Problems come from leaving the defaults in place.
Do I need Angular Universal for SEO?
No. Universal's functionality now lives in @angular/ssr. Add it with ng add @angular/ssr.
Should I use SSR or prerendering?
Prerender content that is the same for every user and changes rarely, such as marketing pages and most blog posts. Use SSR where the first response must reflect fresh or request-specific data. Use client rendering only for pages you don't want indexed.
Does SSR guarantee better rankings?
No. SSR improves what crawlers can read and how quickly content appears. Rankings still depend on content quality, internal linking, canonical URLs, structured data, and overall site performance.
Can Google index a client-side Angular app?
Often yes, because Google renders JavaScript. Rendering takes extra resources, and other crawlers and social scrapers may see nothing. Server-rendered HTML removes that uncertainty.
Does SSR help with AI search tools?
It can. Not every crawler executes JavaScript the way Google does, so complete HTML is the safer default. Behavior differs by vendor and changes over time, so check each vendor's documentation.

Still have questions? Talk to a specialist

Turn this playbook into rankings.

Get a free audit and a 6-month roadmap built around your highest-intent keywords.

Get a free audit