Angular SEO: Title, Meta Tags, Canonical URLs, Lazy Images and SSR

Angular Tutorial: Beginner to Advanced · 17. Advanced routing, rendering and SEO · 5 min read · Updated

Search engines and social networks read your page's <title>, <meta> tags and content. A single page app starts with one empty HTML file, so you must update those tags yourself as the user navigates.

What you will learn

  • The Title and Meta services
  • Dynamic titles with the route title option and a custom TitleStrategy
  • Dynamic meta tags, Open Graph and canonical links
  • Lazy loading images and NgOptimizedImage
  • Why server-side rendering (SSR) matters for SEO

Static titles per route

The easiest way: give every route a title. The router sets document.title for you.

// File: routes
{ path: 'pricing', component: Pricing, title: 'Pricing | My App' }

For a title that depends on data, use a function (a ResolveFn<string>):

// File: dynamic title from a resolver
{ path: 'posts/:id', component: Post, title: (route) => 'Post ' + route.paramMap.get('id') + ' | My App' }

To add the site name to every title, create a TitleStrategy once:

// src/app/seo/app-title-strategy.ts
import { Injectable, inject } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';

@Injectable({ providedIn: 'root' })
export class AppTitleStrategy extends TitleStrategy {
  private readonly title = inject(Title);

  updateTitle(snapshot: RouterStateSnapshot): void {
    const page = this.buildTitle(snapshot); // the route's title option
    this.title.setTitle(page ? page + ' | My App' : 'My App');
  }
}
// register: { provide: TitleStrategy, useExisting: AppTitleStrategy }

The Title and Meta services

For things the route cannot know, use the services directly:

// src/app/seo/seo.ts
import { DOCUMENT } from '@angular/common';
import { Injectable, inject } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';

export interface PageSeo {
  title: string;
  description: string;
  image?: string;
  url: string;
}

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

  update(page: PageSeo) {
    this.title.setTitle(page.title);
    // updateTag replaces an existing tag instead of adding a duplicate
    this.meta.updateTag({ name: 'description', content: page.description });
    // Open Graph: used by Facebook, LinkedIn, WhatsApp and others
    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(url: string) {
    let link = this.doc.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);
  }
}

Use it in a page when its data is ready:

// src/app/seo/blog-post.ts
import { Component, inject, OnInit } from '@angular/core';
import { Seo } from './seo';

@Component({ selector: 'app-blog-post', template: '<h1>Learn Angular signals</h1>' })
export class BlogPost implements OnInit {
  private readonly seo = inject(Seo);

  ngOnInit() {
    this.seo.update({
      title: 'Learn Angular signals | My App',
      description: 'A simple guide to signals with examples.',
      url: 'https://example.com/blog/signals',
    });
  }
}

What each tag does:

  • title and description: what Google shows in the search result.
  • canonical: tells search engines which URL is the "main" one when the same page opens at several addresses (with ?utm= or a trailing slash). It prevents duplicate content.
  • Open Graph (og:*): the preview card when someone shares your link.

Lazy loading images

Large images slow the first screen. Two simple rules:

<!-- below the fold: the browser downloads it only when it is near the viewport -->
<img src="/photo.jpg" alt="Team at work" width="600" height="400" loading="lazy" />

Always give width, height and a meaningful alt. Width and height reserve space so the page does not jump (a Core Web Vital called CLS).

Angular's NgOptimizedImage directive checks these for you and lazy loads by default. Use priority on the main banner image only:

// src/app/seo/hero-image.ts
import { NgOptimizedImage } from '@angular/common';
import { Component } from '@angular/core';

@Component({
  selector: 'app-hero-image',
  imports: [NgOptimizedImage],
  template: `
    <img ngSrc="/hero.jpg" width="1200" height="600" priority alt="Our product" />
    <img ngSrc="/team.jpg" width="600" height="400" alt="The team" />
  `,
})
export class HeroImage {}

Use ngSrc instead of src. The directive warns in development if you forget the size.

SSR and SEO

Google can run JavaScript, but other crawlers and social sites often cannot. With server-side rendering the server sends finished HTML, including your title and meta tags, so everyone sees the content immediately. You also get a faster first paint. Add it to a project with ng add @angular/ssr (see the SSR lesson). Rules for SEO with SSR:

  • Set title and meta before the page finishes rendering (in ngOnInit or from a resolver), not after a timer.
  • Do not touch window directly. Use the injected DOCUMENT, as above.
  • Keep one <h1> per page, use real links (routerLink renders <a href>), and keep a sitemap.xml and robots.txt in the public folder.

If you cannot use SSR, prerender your public pages at build time with static site generation. It gives the same HTML for pages that rarely change.

Test it

// src/app/seo/seo.spec.ts
import { DOCUMENT } from '@angular/common';
import { TestBed } from '@angular/core/testing';
import { Seo } from './seo';

describe('Seo', () => {
  it('sets title, description and one canonical link', () => {
    const seo = TestBed.inject(Seo);
    const doc = TestBed.inject(DOCUMENT);
    seo.update({ title: 'Hello', description: 'Desc', url: 'https://example.com/a' });
    seo.update({ title: 'Hello 2', description: 'Desc 2', url: 'https://example.com/b' });

    expect(doc.title).toBe('Hello 2');
    expect(doc.querySelector('meta[name="description"]')?.getAttribute('content')).toBe('Desc 2');
    expect(doc.querySelectorAll('meta[name="description"]').length).toBe(1);
    expect(doc.querySelectorAll('link[rel="canonical"]').length).toBe(1);
    expect(doc.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe('https://example.com/b');
  });
});

Common mistakes

  • Using addTag on every navigation, which creates duplicate tags. Use updateTag.
  • The same title and description on every page.
  • A canonical URL that points to a different page than the content.
  • Lazy loading the main hero image, which hurts the loading speed.

Try it yourself

  1. Give every route a unique title.
  2. Add the Seo service to two pages and check <head> in the browser tools.
  3. Replace an <img> with NgOptimizedImage and read its console warnings.

Quick check

  1. Why use updateTag and not addTag?
  2. What problem does a canonical link solve?
  3. Which image should have priority?

Next, continue with Advanced Angular Testing: Guards, Interceptors, Routing and Component Inputs.

Frequently asked questions

Does Angular hurt SEO?

A client-only app can rank, but crawlers and social previews work best with real HTML. Use SSR or prerendering for public pages and keep titles, descriptions and canonical links unique.

Is NgOptimizedImage required?

No, but it prevents common mistakes: missing sizes, no lazy loading, and slow loading of the main image.