# Angular SEO: SSR, Meta Tags and the Content Your App Is Missing
Author: Sai Krishna
Author URL: https://superblog.ai/blog/author/sai-krishna/
Published: 2026-11-06
Category: SEO
Category URL: https://superblog.ai/blog/category/seo/
Meta Title: Angular SEO: SSR, Meta Tags and Prerendering | Superblog
Meta Description: Angular SEO for v22: SSR and prerendering with @angular/ssr, per-route titles, meta tags and canonicals, plus where to run the blog that ranks.
Tags: SEO, subdirectory, developer, blog seo, performance
Tag URLs: SEO (https://superblog.ai/blog/tag/seo/), subdirectory (https://superblog.ai/blog/tag/subdirectory/), developer (https://superblog.ai/blog/tag/developer/), blog seo (https://superblog.ai/blog/tag/blog-seo/), performance (https://superblog.ai/blog/tag/performance/)
URL: https://superblog.ai/blog/angular-seo/

Angular SEO is the work of getting real HTML, a unique title and a canonical URL into the first response your server sends for every public route. A default Angular app sends an `<app-root>` with nothing inside it and builds the page in the browser. Google can usually render it later. Many other bots never do. This guide covers the fixes in current Angular (v22, the version angular.dev documents as of November 2026), with code, then the part technical fixes do not solve: the content that earns rankings.

## Is Angular good for SEO?

Angular is fine for SEO once the server sends real HTML. Out of the box it does not. A project created without SSR renders on the client, and Angular's own documentation states the cost plainly in its [server-side rendering guide](https://angular.dev/guide/ssr):

> "If your page is indexed by search crawlers, 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."

So the framework is not the problem. The rendering mode is. An Angular app that serves prerendered or server-rendered HTML gives crawlers the same thing a static site does: headings, body copy, links and metadata in the first response. (The same rendering question decides [React SEO](/blog/react-seo/) and [Next.js SEO](/blog/nextjs-seo/).)

## What a crawler receives from a client-rendered Angular app

View the source of a client-rendered Angular page (not the DevTools element tree, the raw response). You will see the `<head>` from `index.html`, an `<app-root>` element with nothing inside it, and a list of script tags. Every word of your page arrives later, after those scripts download and run.

That empty first response affects two kinds of crawler differently:

1. **Googlebot.** Google's [JavaScript SEO basics](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics) page says "Googlebot queues all pages with a 200 HTTP status code for rendering, unless a robots meta tag or header tells Google not to index the page." Rendering happens later, once Google has capacity, and a page that returns a non-200 status may never be rendered at all. Google still recommends server rendering or prerendering, for two reasons: faster pages for everyone, and bots that cannot run JavaScript.
2. **AI crawlers.** A [December 2024 analysis by Vercel](https://vercel.com/blog/the-rise-of-the-ai-crawler) of real crawler traffic found that "none of the major AI crawlers currently render JavaScript," and its list included OpenAI's and Anthropic's bots. Send one of them to a client-rendered Angular route and it reads an empty `<app-root>`.

The split between bots matters once you write robots.txt rules. OpenAI separates training (GPTBot) from ChatGPT search citations (OAI-SearchBot). Anthropic separates three jobs: ClaudeBot gathers training data, Claude-User fetches a page when a person asks Claude about it, and Claude-SearchBot serves Claude's web search. Blocking Google-Extended keeps your pages out of Gemini training and Gemini grounding. Google Search ignores that token, so AI Overviews still follow your Googlebot rules.

The fix for both is the same: produce the HTML before it leaves your server. (For the crawl side in more depth, see [what crawling in SEO means](/blog/seo-crawling/).)

## The Angular SEO checklist for v22 apps

Work through these in order. Rendering comes first because every later item depends on it: a perfect meta tag that only exists after JavaScript runs is invisible to the bots that skip JavaScript.

### 1\. Add server rendering with @angular/ssr

The package you want is `@angular/ssr`. If you have read older tutorials, Angular Universal is the same idea under its previous name. The [angular/universal repository](https://github.com/angular/universal) says the project moved into the Angular CLI repo in version 17, with the code now mostly under `@angular/ssr`, and the repo is in maintenance mode. Ignore guides that tell you to install `@nguniversal/*` packages for a current app.

Start a new project with server rendering:

```bash bash
ng new --ssr

```

Or add it to an existing one:

```bash bash
ng add @angular/ssr

```

The CLI generates a server entry and a server config. Per the docs, Angular then prerenders your whole app by default and also generates a server file for requests. You choose how each route renders in the next step.

### 2\. Pick a render mode per route

Current Angular lets you set rendering per route in a file usually named `app.routes.server.ts`. There are three modes, all imported from `@angular/ssr`:

Render modeWhat happensUse it for`RenderMode.Prerender`HTML generated at build time (SSG)Marketing pages, docs, pricing, anything identical for every visitor`RenderMode.Server`HTML generated per request (SSR)Pages whose content changes too often to rebuild, or depends on the request`RenderMode.Client`Rendered in the browser (CSR)Logged-in dashboards and settings that should never be indexed

A typical split for a SaaS app looks like this:

```ts ts
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  { path: '', renderMode: RenderMode.Prerender },
  { path: 'pricing', renderMode: RenderMode.Prerender },
  // 'features/:slug' is added in step 3: parameterized routes need getPrerenderParams
  { path: 'app/**', renderMode: RenderMode.Client },
  { path: '**', renderMode: RenderMode.Server },
];

```

Register it in your server config with `provideServerRendering(withRoutes(serverRoutes))`, which is how the [official guide](https://angular.dev/guide/ssr) wires it up in `app.config.server.ts`.

For public pages, prefer `Prerender`. The docs say prerendering gives faster page loads than both client and server rendering, and that static files cache well on CDNs. That is exactly what you want a crawler to hit.

### 3\. Prerender parameterized routes with getPrerenderParams

A route like `features/:slug` has no single URL to prerender, and Angular fails the build when a prerendered route has parameters but no `getPrerenderParams`. Give Angular the list with `getPrerenderParams`, which runs at build time and returns one object per page:

```ts ts
import { inject } from '@angular/core';
import { RenderMode, ServerRoute } from '@angular/ssr';
import { FeatureCatalog } from './feature-catalog';

export const serverRoutes: ServerRoute[] = [
  {
    path: 'features/:slug',
    renderMode: RenderMode.Prerender,
    async getPrerenderParams() {
      const catalog = inject(FeatureCatalog); // call inject() before any await
      const slugs = await catalog.allSlugs();
      return slugs.map((slug) => ({ slug }));
    },
  },
];

```

The docs flag two rules here. `inject` must be called synchronously, not after an `await`. And because this function always runs at build time, it must not depend on browser or server APIs. Keep that slug list handy: you will reuse it for the sitemap.

If you want no Node server at all, set `"outputMode": "static"` in the build options in `angular.json`. Angular then writes prerendered HTML files and no server file, so the output deploys to any static host or CDN. Static output has no server, so `RenderMode.Server` is off the table: Angular's builder rejects named routes set to it in this mode. The real-404 setup in the next step then has to come from your host instead.

### 4\. Return the right status codes and redirects

Soft 404s are a common Angular SEO leak. The router happily renders a "not found" component with a 200 status, and Google's [JavaScript SEO guide](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics) names this soft 404 pattern as a known single-page-app problem.

On server-rendered routes, set the status from the component using the `RESPONSE_INIT` token from `@angular/core`, which the docs describe as the way to set headers or status codes determined at runtime:

```ts ts
import { Component, inject, RESPONSE_INIT } 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; // null in the browser and during prerendering
    }
  }
}

```

The token is `null` during builds, in the browser and during static generation, so this only takes effect on `RenderMode.Server` routes. Route your `**` catch-all to the server for that reason. Entries in `serverRoutes` also accept fixed `headers`, and server-rendered and client-rendered entries accept a fixed `status`. Prerendered entries do not take a status.

On a fully static build there is no server to set that status. Serve a `404.html` from your host, or follow Google's fallback for single-page apps and add a `<meta name="robots" content="noindex">` tag to the not-found component (see the soft 404 section of Google's JavaScript SEO guide).

Redirects behave differently by mode, and this catches teams out. The SSR guide says server-rendered redirects use standard HTTP 301 or 302 responses, while for prerendered pages, "Redirects are implemented as "soft redirects" using `<meta http-equiv="refresh">` tags in the prerendered HTML." If you are moving URLs that have rankings, issue real 301s at your host or CDN rather than relying on a prerendered `redirectTo`.

### 5\. Keep hydration on and server output stable

Hydration lets the browser reuse the server-rendered DOM instead of rebuilding it. The [hydration guide](https://angular.dev/guide/hydration) explains why that matters for Core Web Vitals: "Without hydration enabled, server-side rendered Angular applications will destroy and re-render the application's DOM, which may result in a visible UI flicker." It also says the CLI already includes the hydration setup ( `provideClientHydration()`) when you enable SSR at creation or through `ng add @angular/ssr`, so check it is still in your providers after any refactor.

The other half is not breaking it. The SSR guide warns against using `isPlatformBrowser` inside template conditionals to render different content on server and client, because that "causes hydration mismatches and layout shifts." Put browser-only work in `afterNextRender` and keep the markup identical on both sides.

## Angular meta tags, titles and canonicals per route

Every indexable URL needs its own `<title>`, meta description, canonical link and Open Graph tags. With server rendering in place, anything you set during render lands in the HTML crawlers receive.

### Titles from the router

For static titles, use the route's `title` property. The [routing guide](https://angular.dev/guide/routing/define-routes) says Angular updates the page title automatically when a route activates, and the property also accepts a `ResolveFn` for dynamic titles:

```ts ts
export const routes: Routes = [
  { path: 'pricing', component: Pricing, title: 'Pricing | Acme' },
  { path: 'features/:slug', component: FeatureDetail, title: featureTitleResolver },
];

```

### Meta, Open Graph and canonical in one service

Angular ships a `Meta` service ("A service for managing HTML `<meta>` tags") and a `Title` service in `@angular/platform-browser`. There is no built-in canonical helper, so inject the `DOCUMENT` token from `@angular/core` and manage the `<link>` yourself. Do not touch the global `document` directly: it does not exist during server rendering.

```ts ts
import { DOCUMENT, Injectable, inject } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';

export interface PageSeo {
  title: string;
  description: string;
  url: string; // absolute, e.g. https://acme.com/features/reports
  image?: string;
}

@Injectable({ providedIn: 'root' })
export class SeoService {
  private readonly title = inject(Title);
  private readonly meta = inject(Meta);
  private readonly doc = inject(DOCUMENT);

  apply(page: PageSeo): void {
    this.title.setTitle(page.title);
    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 });
    if (page.image) {
      this.meta.updateTag({ property: 'og:image', content: page.image });
    }
    this.setCanonical(page.url);
  }

  private setCanonical(href: string): void {
    let link = this.doc.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
    if (!link) {
      link = this.doc.createElement('link');
      link.rel = 'canonical';
      this.doc.head.appendChild(link);
    }
    link.href = href;
  }
}

```

Call `apply()` from each page component once its data is available. The canonical lookup matters: the docs' own canonical example appends a new `<link>` each time, and on a client-side navigation that leaves you with two conflicting canonicals in the head.

Three rules keep this clean:

1. **One canonical per page, absolute URL, self-referencing.** Never set a canonical in a shared layout component that every route inherits.
2. **Match the server value on the client.** Google's JavaScript SEO page says to always set the canonical URL to the same value as the original HTML. If the server renders one canonical and the browser swaps in another, you have sent mixed signals.
3. **Keep the default tags in `index.html` generic.** Anything left there is the fallback for every route that forgets to set its own.

### JSON-LD structured data for your own pages

Structured data (Organization on the homepage, Product or SoftwareApplication on product pages, FAQPage where you answer questions) also belongs in the server HTML. Do not try to bind it with `[innerHTML]`. Angular's [security guide](https://angular.dev/best-practices/security) explains that the sanitizer treats bound HTML as unsafe and removes `script` elements from it, so the tag never reaches the page.

Add a method to the same `SeoService` that creates or reuses one `<script type="application/ld+json">` through `DOCUMENT`, the same way `setCanonical` handles the link:

```ts ts
setJsonLd(data: Record<string, unknown>): void {
  let script = this.doc.head.querySelector<HTMLScriptElement>(
    'script[type="application/ld+json"]',
  );
  if (!script) {
    script = this.doc.createElement('script');
    script.type = 'application/ld+json';
    this.doc.head.appendChild(script);
  }
  script.textContent = JSON.stringify(data).replace(/</g, '\\u003c');
}

```

Reusing the element means a client-side navigation replaces the previous page's schema instead of stacking a second block on top of it. The `replace` call keeps a stray `</script>` inside your data from closing the tag early in the server-rendered HTML.

## Sitemaps and robots.txt in an Angular project

The Angular SSR guide does not cover sitemap generation, so you build one. The cleanest source is the same data your `getPrerenderParams` functions already fetch: write a small build script that requests those slugs, adds your static routes and outputs `sitemap.xml`.

Put `robots.txt` and the generated `sitemap.xml` in the `public/` folder. Angular's [file structure reference](https://angular.dev/reference/configs/file-structure) describes it as the folder for files "to be served as static files by the dev server and copied as-is when you build your application." Add a `Sitemap:` line to `robots.txt` pointing at the absolute sitemap URL, and disallow the client-only areas ( `/app/` in the example above).

## Core Web Vitals for Angular

Rendering on the server fixes discovery. Speed still has to be earned:

- **Prioritize the LCP image.** Use `NgOptimizedImage` from `@angular/common`: swap `src` for `ngSrc`, set `width` and `height`, and add `priority` to the largest above-the-fold image. The [image optimization guide](https://angular.dev/guide/image-optimization) says marking an image as priority sets `fetchpriority` on it, and tells you to always mark the LCP image this way.
- **Lazy load routes.** Use `loadComponent` (or `loadChildren`) so a visitor landing on a feature page does not download the dashboard.
- **Serve prerendered pages from a CDN.** Static HTML at the edge gives the fastest time to first byte you can get.
- **Watch third-party scripts.** Chat widgets and tag managers compete with your app for main-thread time, so load them after the page is interactive.

Check results in PageSpeed Insights and the Core Web Vitals report in Search Console, and confirm rendering with the URL Inspection tool's rendered HTML view.

## Crawlable is not the same as findable

Run the full checklist and your Angular app becomes crawlable. It does not become findable for the searches that bring buyers. Your homepage, pricing page and feature pages will rank for your brand name and a few product terms. The queries prospects type before they know you exist ("how to reconcile invoices in bulk", "\[competitor\] alternative", "\[your category\] for small teams") are answered by articles, and an app has no articles.

That means a blog: dozens to hundreds of posts, each needing its own title tag, canonical, structured data, sitemap entry and fast page. Which brings up the practical question every Angular team hits next: where should those posts live, and who maintains the pages they run on? Here are the four realistic answers, from building it in Angular to running it on a managed platform such as Superblog.

## Where to run the blog for an Angular site

The blog should sit on your main domain at `/blog`, so links earned by posts strengthen the same domain as your product pages. (We compare the two setups in [subdomain vs subdirectory](/blog/subdomain-vs-subdirectory/).)

### 1\. Build the blog inside your Angular app

Store posts as Markdown or in a database, add `blog/:slug` with `RenderMode.Prerender` and a `getPrerenderParams` that lists every slug, and render them with a Markdown pipeline.

**What you get:** one codebase, one deploy, full control of markup.

**What you take on:** every piece of blog infrastructure becomes your team's code. That includes an editor non-developers can use, drafts and scheduling, image resizing, Article and FAQ JSON-LD, RSS, the blog sitemap, category and tag pages, author pages, related posts, and hreflang if you translate. Each new post also means a rebuild and redeploy of the app. For a team whose roadmap is the product, the blog tends to stall after the first sprint.

### 2\. Superblog at yoursite.com/blog

[Superblog](https://superblog.ai) is a fully managed blogging platform: the CMS, the blog frontend, hosting and the SEO layer in one product. Your marketing team writes in the dashboard and publishes. Your Angular components stay as they are: the setup is a routing rule at your host (a small proxy function on Firebase Hosting) plus two checks on the Angular side, covered below.

The blog side covers the checklist above without anyone writing it:

- **Pages are prebuilt static HTML** served from a global CDN with 200+ edge locations, so crawlers and AI bots get complete content in the first response, with a 90+ Lighthouse score.
- **JSON-LD you never write by hand:** Article (or BlogPosting), Organization, WebPage and ImageObject on every post, and FAQ wherever a post has an FAQ block.
- **XML sitemaps rebuilt on every deploy**, and IndexNow pings on publish so Bing and other supporting engines hear about new posts right away.
- **Search snippets you control post by post:** title tag, meta description, canonical and Open Graph fields on every post, plus a SERP preview in the editor.
- **Every post mirrored as plain Markdown and listed in `yoursite.com/blog/llms.txt`**, alongside the server-rendered HTML, so ChatGPT and Claude can quote your articles from a clean source.
- **Multilingual SEO with hreflang**, on Super and above.

Pricing as of November 2026: Pro at $49/month and Super at $99/month, with a 7-day free trial that needs no credit card.

**Trade-off:** Superblog runs your blog, not your app. Product pages stay in Angular.

### 3\. A headless CMS with Angular as the frontend

Contentful, Sanity, Strapi and similar tools store content and give you an API. Your team still builds and maintains every blog template in Angular, along with the schema, sitemap and feed logic from option 1. You trade a homegrown editor for a hosted one. The frontend and SEO work stays yours.

### 4\. WordPress beside the app

WordPress gives writers a familiar editor. Running it at `/blog` behind your Angular host means a separate server, plugin and security updates, and SEO plugins to configure. Many teams settle for `blog.yoursite.com` instead, which splits authority across two hosts.

OptionWho builds blog pagesSEO layerLives at /blogOngoing engineeringBlog inside AngularYour developersYou write itYesHighSuperblogIncludedAutomaticYes, via a host routing ruleNear zeroHeadless CMS + AngularYour developersYou write itYesMedium to highWordPressTheme + pluginsPluginsPossible, via proxyMedium

## Running Superblog next to your Angular app

Superblog hosts each blog on its own origin (for example `yourblog.superblog.click`), and [subdirectory hosting](/features/subdirectory-hosting) maps that origin onto your domain. A rule at your host or CDN sends requests for `/blog` and `/blog/*` to that origin, strips the `/blog` prefix, and returns the response under your domain. Visitors and crawlers only ever see `yoursite.com/blog/...`.

Where the rule lives depends on how you deploy Angular, not on Angular itself. Superblog's dashboard generates the exact configuration for the hosts Angular teams commonly use, including Firebase Hosting, Netlify, Vercel, Nginx, Apache, IIS, AWS Amplify, AWS CloudFront, Cloudflare and Render.

Firebase Hosting is a common home for Angular apps and works a little differently. Its [configuration reference](https://firebase.google.com/docs/hosting/full-config) defines a rewrite `destination` as "A local file that must exist," and offers separate `function` and `run` targets for dynamic content. So the dashboard's Firebase guide routes the blog through a small Cloud Function proxy, then points both paths at it in `firebase.json`. Cloud Functions need Firebase's Blaze (pay-as-you-go) plan to [deploy](https://firebase.google.com/docs/functions/get-started), and the dashboard's Firebase guide says so up front.

```json json
"rewrites": [
  {
    "source": "/blog",
    "function": { "functionId": "superblogProxy", "region": "us-central1" }
  },
  {
    "source": "/blog/**",
    "function": { "functionId": "superblogProxy", "region": "us-central1" }
  }
]

```

Firebase applies the first matching rewrite, so these two rules must sit above the `"source": "**"` catch-all that serves your Angular `index.html`. Otherwise the app keeps answering `/blog` and the blog never loads.

On Nginx, Netlify, Vercel or Cloudflare the rule is a proxy block, a 200 rewrite or a Worker route. Our [subdirectory blog hosting guide](/blog/subdirectory-blog-hosting/) walks through each one, including the trailing-slash and `Host` header details a proxy rule has to get right.

Two Angular-specific checks once it is live:

1. **Make sure the Angular router never claims `/blog`.** If a client-side navigation to `/blog` is handled by your `**` route, users see your 404 component until they refresh. Link to the blog with a plain `<a href="/blog/">`, not `routerLink`, so the browser makes a real request.
2. **Exclude `/blog` from your service worker** if you use `@angular/service-worker`. The SSR guide notes that after the first request, the Angular service worker handles subsequent navigations client-side, so a visitor who already loaded your app can get the Angular shell for a blog URL instead of the blog. To exclude it, add a `navigationUrls` array to `ngsw-config.json` that keeps Angular's four [default patterns](https://angular.dev/ecosystem/service-workers/config) and adds two negative patterns for the blog, one for `/blog` itself and one for everything under it: `"navigationUrls": ["/**", "!/**/*.*", "!/**/*__*", "!/**/*__*/**", "!/blog", "!/blog/**"]`.

For why static HTML beats runtime rendering for a content site specifically, see [static vs dynamic blogs](/blog/static-vs-dynamic-blog/).

## FAQ

### Do I need a Node server for Angular SEO?

No. Set `outputMode` to `static` in `angular.json` and Angular prerenders your routes to HTML files that any static host or CDN can serve. Status codes for unknown URLs then come from your host.

### Is Angular Universal still needed for Angular SSR SEO?

No. Universal moved into the Angular CLI in version 17 and lives on as `@angular/ssr`. Run `ng add @angular/ssr` on a current project instead of installing the old `@nguniversal` packages.

### Should I use SSR or prerendering for SEO?

Prerendering for any page that is the same for every visitor, which covers most public marketing pages. Use `RenderMode.Server` when content changes too often to rebuild or depends on the request, and for the catch-all route so real 404 status codes work.

### Can I set Angular meta tags without SSR?

You can set them with the `Meta` and `Title` services, and Google may pick them up after rendering. Crawlers that skip JavaScript, including the AI crawlers in Vercel's 2024 study, will only see the defaults in `index.html`.

### Does Google index client-rendered Angular apps?

Usually, after a delay in the render queue. Google's own guidance still recommends server-side or pre-rendering because not all bots run JavaScript.

### What is the fastest way to add a blog to an Angular app?

Run the blog on a managed platform at `/blog` and route that path to it at your host. With Superblog, the blog pages, schemas, sitemaps and hosting come with the platform, and your Angular code needs no rewrite.

## Your Angular SEO punch list

Prerender your public routes, set real status codes, give every page its own title, description and canonical, and generate a sitemap. That makes your Angular app visible. Then put a blog at `/blog` so it has something worth ranking for.

[Start a Superblog trial](https://write.superblog.ai) to set up the blog and draft your first posts. Connecting it at `yoursite.com/blog` comes with a paid plan, and your Angular components stay as they are.


---
This blog is powered by Superblog. Visit https://superblog.ai to know more.
---

