📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~9 min de lecture

Arrête de traîner Title.setTitle() dans chaque page : le pattern TitleStrategy qui centralise ton SEO

Tu ouvres ton app en prod. Sur /pricing, l'onglet affiche Pricing | MyApp. Sur /blog/foo, c'est MyApp - Blog - Foo. Sur /settings, juste Settings. Et sur /checkout, le titre par défaut du index.html n'a jamais bougé. Quatre pages, quatre conventions, un désastre pour le SEO et pour l'utilisateur qui essaye de retrouver un onglet dans son navigateur.

Le coupable est simple : quelqu'un dans ton équipe a appris qu'Angular fournit Title et Meta via @angular/platform-browser. Depuis, chaque composant fait son propre title.setTitle('...'), tantôt dans ngOnInit, tantôt dans un afterNextRender() plus moderne, avec sa propre convention. Personne n'a jamais posé la règle. Personne ne peut la changer sans passer sur 40 fichiers.

Angular a une solution built-in depuis la v14, et à peu près personne ne l'utilise : TitleStrategy. Un point d'entrée unique, appelé par le Router à chaque navigation. Tu poses ta convention une fois, tu la changes une fois, tu la testes une fois. Voici comment.

TitleStrategy, la clé title dans les routes et ResolveFn<string> sont arrivés ensemble en Angular 14. Les exemples utilisent l'API moderne (signals, inject(), resolvers fonctionnels, décorateur @Service v22+). Si tu es sur du plus ancien, @Injectable({ providedIn: 'root' }) remplace @Service() sans autre changement.


Le vrai problème derrière Title.setTitle()

Tu crois que le problème c'est trois lignes dupliquées. C'est pire.

1. Convention orale, donc convention perdue. Le premier dev a mis Pricing | MyApp. Le suivant a vu ça, a copié le format en oubliant l'espace autour du séparateur. Le troisième a fait MyApp - Pricing parce que dans son ancienne boîte c'était comme ça. Six mois plus tard, tu veux passer tout le site à Pricing - MyApp (parce que Google affiche mieux le premier mot). Il faut faire un grep sur setTitle(, ouvrir chaque fichier, corriger, retester. Multiplie par le nombre de langues si tu es en i18n.

2. Titres oubliés = pages fantômes. Un composant nouveau, un dev qui découvre l'app, il oublie de setter le titre. Résultat : la page hérite du titre de la page précédente (le Router ne reset rien tout seul). Ta prod affiche Pricing | MyApp sur /blog/article-foo parce que le user est arrivé depuis pricing et que le composant blog n'a jamais appelé setTitle. Difficile à repérer, terrible pour Google.

3. Testing pénible. Tester qu'un composant appelle title.setTitle('X') avec le bon argument, ça multiplie les tests par le nombre de pages, et ça teste une plomberie qui n'a rien à voir avec le métier du composant. On teste le mauvais niveau.

4. Aucun point unique pour le SEO cousin. Le titre ne vit jamais seul. Il va avec une <meta name="description">, un <meta property="og:title">, une balise canonical. Si le titre est éparpillé dans les composants, la description l'est aussi. Chaque page rejoue la même plomberie.

Le fix n'est pas d'ajouter une convention "toujours faire ${page} | MyApp". Le fix est de sortir la responsabilité du composant.


La solution built-in : title dans la route + TitleStrategy

Angular fournit deux briques complémentaires, pensées pour marcher ensemble.

Brique 1 : la clé title dans la définition de route. Depuis Angular 14, une route accepte title: 'Pricing' (string statique) ou title: () => ... (resolver fonctionnel qui peut faire un appel HTTP). Le Router récupère cette valeur à chaque navigation et la passe à... la seconde brique.

Brique 2 : TitleStrategy, un service DI que tu peux override. Le Router appelle strategy.updateTitle(routerState) après chaque navigation. La stratégie par défaut, DefaultTitleStrategy, se contente de faire title.setTitle(this.buildTitle(routerState)). Si tu fournis ta propre implémentation, tu contrôles tout : format, suffixe, fallback, et par la même occasion tu peux setter <meta> ou déclencher analytics.

L'intérêt : les composants n'ont plus rien à faire. Ils ne connaissent pas leur titre. Il est déclaré à côté de la route, ce qui est le bon endroit (une route = une intention = un titre).

Étape 1 : déclarer les titres dans les routes

import { Routes } from '@angular/router';

export const APP_ROUTES: Routes = [
  {
    path: '',
    title: 'Accueil',
    loadComponent: () => import('./pages/home/home.page'),
  },
  {
    path: 'pricing',
    title: 'Tarifs',
    loadComponent: () => import('./pages/pricing/pricing.page'),
  },
  {
    path: 'blog/:slug',
    title: blogTitleResolver,
    loadComponent: () => import('./pages/blog-post/blog-post.page'),
  },
];

Le blogTitleResolver est un simple ResolveFn<string> :

import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router';
import { BlogService } from './blog.service';

export const blogTitleResolver: ResolveFn<string> = (route: ActivatedRouteSnapshot) => {
  const slug = route.paramMap.get('slug')!;
  return inject(BlogService).getPostTitle(slug);
};

ResolveFn peut retourner string, Promise<string> ou Observable<string>. Le Router attend la résolution avant de setter le titre. Aucun composant impliqué.

Étape 2 : fournir une TitleStrategy custom

import { Service, inject } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';

@Service()
export class AppTitleStrategy extends TitleStrategy {
  private readonly title = inject(Title);
  private readonly suffix = 'MyApp';
  private readonly separator = ' | ';

  override updateTitle(snapshot: RouterStateSnapshot): void {
    const routeTitle = this.buildTitle(snapshot);
    const fullTitle = routeTitle
      ? `${routeTitle}${this.separator}${this.suffix}`
      : this.suffix;
    this.title.setTitle(fullTitle);
  }
}

buildTitle() est fourni par la classe mère : il remonte la hiérarchie de routes et prend le dernier title défini (celui de la feuille active). Renvoie undefined si aucune route de la chaîne n'a de titre, à toi de gérer le fallback.

Étape 3 : la brancher

import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { TitleStrategy } from '@angular/router';
import { AppTitleStrategy } from './app-title.strategy';
import { APP_ROUTES } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(APP_ROUTES),
    { provide: TitleStrategy, useClass: AppTitleStrategy },
  ],
};

C'est tout. Chaque navigation passe par AppTitleStrategy. Les composants n'importent plus Title, n'ont plus de ngOnInit qui set un titre, ne testent plus cette plomberie.


Before/after : la migration concrète

Avant : titre dans chaque composant

import { ChangeDetectionStrategy, Component, OnInit, inject } from '@angular/core';
import { Title } from '@angular/platform-browser';

@Component({
  selector: 'app-pricing',
  templateUrl: './pricing.page.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class Pricing implements OnInit {
  private readonly titleService = inject(Title);

  ngOnInit(): void {
    this.titleService.setTitle('Tarifs | MyApp');
  }
}

Multiplie par toutes tes pages. Chacune a sa propre convention, son propre bug potentiel, ses propres tests.

Après : composant nu, titre déclaré à côté de la route

import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'app-pricing',
  templateUrl: './pricing.page.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class Pricing {}
// app.routes.ts
{ path: 'pricing', title: 'Tarifs', loadComponent: () => import('./pages/pricing/pricing.page') }

Le composant a une seule responsabilité : afficher les tarifs. Le titre est data-driven, cohérent, testable une fois.


Le pattern qui va plus loin : SEO complet en un seul point

Le titre n'arrive jamais seul. Tu veux aussi une <meta description>, un og:title, une canonical. Tu peux mettre toute cette logique dans la même stratégie, avec des champs custom dans route.data.

import { Service, inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { Meta, Title } from '@angular/platform-browser';
import { Router, RouterStateSnapshot, TitleStrategy } from '@angular/router';

interface PageSeoData {
  description?: string;
  ogImage?: string;
  noIndex?: boolean;
}

@Service()
export class SeoTitleStrategy extends TitleStrategy {
  private readonly title = inject(Title);
  private readonly meta = inject(Meta);
  private readonly router = inject(Router);
  private readonly document = inject(DOCUMENT);
  private readonly suffix = 'MyApp';
  private readonly origin = 'https://myapp.com';

  override updateTitle(snapshot: RouterStateSnapshot): void {
    const routeTitle = this.buildTitle(snapshot) ?? '';
    const fullTitle = routeTitle ? `${routeTitle} | ${this.suffix}` : this.suffix;
    this.title.setTitle(fullTitle);

    const leaf = this.leafData(snapshot);
    this.updateMeta(fullTitle, leaf);
    this.updateCanonical(snapshot.url);
  }

  private leafData(snapshot: RouterStateSnapshot): PageSeoData {
    let route = snapshot.root;
    while (route.firstChild) {
      route = route.firstChild;
    }
    return (route.data ?? {}) as PageSeoData;
  }

  private updateMeta(fullTitle: string, data: PageSeoData): void {
    if (data.description) {
      this.meta.updateTag({ name: 'description', content: data.description });
      this.meta.updateTag({ property: 'og:description', content: data.description });
    } else {
      this.meta.removeTag('name="description"');
      this.meta.removeTag('property="og:description"');
    }

    this.meta.updateTag({ property: 'og:title', content: fullTitle });
    this.meta.updateTag({ property: 'og:image', content: data.ogImage ?? `${this.origin}/og-default.png` });
    this.meta.updateTag({ name: 'robots', content: data.noIndex ? 'noindex,nofollow' : 'index,follow' });
  }

  private updateCanonical(url: string): void {
    const cleanUrl = url.split('?')[0].split('#')[0];
    const href = `${this.origin}${cleanUrl}`;
    let link = this.document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
    if (!link) {
      link = this.document.createElement('link');
      link.setAttribute('rel', 'canonical');
      this.document.head.appendChild(link);
    }
    link.setAttribute('href', href);
  }
}

Côté routes, tu passes les champs SEO dans data :

export const APP_ROUTES: Routes = [
  {
    path: 'pricing',
    title: 'Tarifs',
    data: {
      description: 'Trois formules pour démarrer : Free, Pro, Team. Sans engagement.',
      ogImage: 'https://myapp.com/og/pricing.png',
    },
    loadComponent: () => import('./pages/pricing/pricing.page'),
  },
  {
    path: 'checkout',
    title: 'Paiement',
    data: { noIndex: true },
    loadComponent: () => import('./pages/checkout/checkout.page'),
  },
];

Une navigation, un point d'entrée, tout le <head> cohérent. Tu ajoutes une nouvelle page ? Tu remplis title et data. Tu ne touches à rien d'autre.

Si tu es en SSR, TitleStrategy fonctionne à l'identique côté serveur : le titre et les meta sont rendus dans le HTML servi, donc visibles par les crawlers dès la première requête. C'est justement pour ça qu'il ne faut pas les setter dans afterNextRender(), qui ne s'exécute jamais côté serveur (contrairement à ngOnInit, qui tourne bien pendant le SSR) : ce hook n'a simplement pas sa place ici, la responsabilité du <head> doit rester dans un seul endroit, la stratégie.


Piège 1 : le titre du parent qui écrase celui de l'enfant (ou l'inverse)

buildTitle() remonte la hiérarchie et retourne le titre de la feuille active. Concrètement, si tu as :

{
  path: 'blog',
  title: 'Blog',
  children: [
    { path: '', component: BlogList },
    { path: ':slug', title: blogPostTitleResolver, component: BlogPost },
  ],
}
  • Sur /blog, buildTitle() retourne 'Blog' (le parent).
  • Sur /blog/foo, buildTitle() retourne le résultat de blogPostTitleResolver (l'enfant).
  • Sur /blog/foo sans title sur l'enfant, buildTitle() retourne 'Blog' (fallback sur le parent le plus proche qui a un titre).

Le piège classique : tu mets un titre sur le parent "pour être safe", pensant qu'il sera un préfixe. Ce n'est pas un préfixe, c'est un fallback. Si tu veux un préfixe (Blog | Foo | MyApp), il faut le construire toi-même dans la stratégie :

override updateTitle(snapshot: RouterStateSnapshot): void {
  const parts: string[] = [];
  let route = snapshot.root;
  while (route) {
    const t = route.title as string | undefined;
    if (t) parts.push(t);
    route = route.firstChild!;
  }
  parts.push(this.suffix);
  this.title.setTitle(parts.join(' | '));
}

À utiliser avec parcimonie, un titre à rallonge est mauvais pour le SEO. Google tronque au-delà d'environ 60 caractères.


Piège 2 : le resolver de titre relancé à chaque navigation

ResolveFn<string> suit la même politique de rerun que les guards resolver classiques : la stratégie est contrôlée par runGuardsAndResolvers sur la route (paramsChange par défaut). Si ton resolver fait un appel HTTP à chaque changement de query param, tu déclenches un fetch inutile.

Deux options :

1. Passer par un signal partagé. Si l'info est déjà chargée par le composant enfant (via un service qui la met en cache), le resolver la lit depuis le cache :

export const blogTitleResolver: ResolveFn<string> = (route) => {
  const slug = route.paramMap.get('slug')!;
  const cached = inject(BlogCache).peek(slug);
  return cached?.title ?? inject(BlogService).getPostTitle(slug);
};

2. Utiliser runGuardsAndResolvers: 'paramsOrQueryParamsChange' seulement si tu en as besoin. Par défaut, seul un changement de param path relance le resolver, ce qui est déjà correct pour la plupart des cas.


Piège 3 : setter les meta dans ngOnInit "en attendant"

C'est LE compromis pourri qui rend le SEO ingérable. Tu as sorti le titre dans la stratégie, mais tu as gardé this.meta.updateTag({ description: '...' }) dans le ngOnInit du composant, "juste pour la description".

Attention, ngOnInit tourne bien côté serveur en SSR et en prerender (Angular exécute tout l'arbre de composants, hooks compris, pour produire le HTML) : ce n'est donc pas un problème de hook invisible au crawl, contrairement à afterNextRender qui, lui, ne s'exécute jamais côté serveur. Le vrai risque est ailleurs :

  • Course contre la capture SSR. Si la description dépend d'un appel HTTP lancé dans ngOnInit, rien ne garantit qu'il soit résolu avant que le snapshot serveur ne soit figé. Un resolver de route, lui, est explicitement attendu par le Router avant de terminer la navigation, donc la donnée est forcément là.
  • Rémanence entre navigations. Si le composant est réutilisé (même route, param qui change) sans que ngOnInit se rejoue, la meta posée sur la page précédente peut rester affichée telle quelle.
  • Retour à la case départ. Tu recrées exactement le problème que cet article règle : une deuxième source de vérité pour le SEO, dupliquée page par page, qu'un nouveau composant peut oublier.

Règle : tout ce qui vit dans le <head> doit être posé par la stratégie, jamais par un composant. Le composant ne connaît pas son SEO, c'est un choix conscient d'architecture qui te rend le SEO auditable en un fichier.


Tester la stratégie une fois pour toutes

Un seul test unitaire couvre tout :

import { TestBed } from '@angular/core/testing';
import { Title } from '@angular/platform-browser';
import { provideRouter, RouterStateSnapshot } from '@angular/router';
import { SeoTitleStrategy } from './seo-title.strategy';

describe('SeoTitleStrategy', () => {
  let strategy: SeoTitleStrategy;
  let title: Title;

  beforeEach(() => {
    TestBed.configureTestingModule({ providers: [provideRouter([])] });
    strategy = TestBed.inject(SeoTitleStrategy);
    title = TestBed.inject(Title);
  });

  it('concatenates leaf title with suffix', () => {
    const snapshot = { root: fakeRoute('Tarifs', {}) } as unknown as RouterStateSnapshot;
    strategy.updateTitle(snapshot);
    expect(title.getTitle()).toBe('Tarifs | MyApp');
  });

  it('falls back to suffix alone when no route has a title', () => {
    const snapshot = { root: fakeRoute(undefined, {}) } as unknown as RouterStateSnapshot;
    strategy.updateTitle(snapshot);
    expect(title.getTitle()).toBe('MyApp');
  });
});

function fakeRoute(title: string | undefined, data: object) {
  return { title, data, firstChild: null, children: [] };
}

Ton composant Pricing n'a plus rien à tester côté titre. La stratégie est ta seule source de vérité, et elle est testée à part. Aucun test dupliqué page par page.


Récap actionnable

  1. Supprime tout import { Title } from '@angular/platform-browser' de tes composants. Le seul endroit qui a le droit d'importer Title et Meta, c'est ta TitleStrategy.
  2. Déclare le titre sur la route (title: 'Tarifs' ou title: resolverFn), pas dans le composant.
  3. Fournis une TitleStrategy custom dans providers, avec le suffixe, le séparateur et les fallbacks de ton app.
  4. Étends-la à tout le SEO (description, og:*, canonical, noindex) via route.data. Un fichier, une convention, un endroit qui change quand la stratégie change.
  5. Attention au fallback parent → enfant de buildTitle(). Si tu veux un préfixe, construis-le toi-même en itérant la chaîne de routes.
  6. En SSR, tout doit passer par la stratégie. afterNextRender() est browser-only et ne rend rien dans le HTML servi ; ngOnInit tourne bien côté serveur mais te ramène toute la distribution qu'on cherchait à éviter.

Le vrai gain n'est pas d'écrire moins de code (même si c'est le cas). C'est d'avoir enfin un seul fichier à ouvrir quand ton lead SEO te demande de changer le format du titre sur tout le site. Deux minutes au lieu d'une matinée, et tu ne casses plus rien au passage.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.