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
TitleandMetaservices - Dynamic titles with the route
titleoption and a customTitleStrategy - 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
ngOnInitor from a resolver), not after a timer. - Do not touch
windowdirectly. Use the injectedDOCUMENT, as above. - Keep one
<h1>per page, use real links (routerLinkrenders<a href>), and keep asitemap.xmlandrobots.txtin thepublicfolder.
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
addTagon every navigation, which creates duplicate tags. UseupdateTag. - 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
- Give every route a unique
title. - Add the
Seoservice to two pages and check<head>in the browser tools. - Replace an
<img>withNgOptimizedImageand read its console warnings.
Quick check
- Why use
updateTagand notaddTag? - What problem does a canonical link solve?
- Which image should have
priority?
Next, continue with Advanced Angular Testing: Guards, Interceptors, Routing and Component Inputs.