📧 Reste informé(e) !

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

S'inscrire gratuitement

~8 min de lecture

canMatch vs canActivate : pourquoi ton guard HTTP marche en cliquant mais casse en collant l'URL

Tu as un guard qui protège une sous-route. Il fait un appel HTTP pour décider si l'utilisateur a le droit. Tu testes en cliquant sur les liens de ton app : nickel, ça bloque ou ça laisse passer comme prévu. Tu copies l'URL, tu la colles dans un nouvel onglet, tu fais Entrée. Et là, plus rien. 404, page blanche, ou redirection bizarre. Le même guard, la même URL, mais deux comportements opposés selon la façon dont tu arrives sur la page.

Le coupable n'est pas ton appel HTTP. C'est le type de guard dans lequel tu l'as mis. Tu as un canMatch, et tu crois qu'il se comporte comme un canActivate. Sauf que les deux ne tournent pas dans la même phase du Router, ne réagissent pas pareil à un false, et l'un des deux échoue en silence pile au pire moment : le chargement direct par URL.

Voici ce qui se passe vraiment, comment le diagnostiquer en dix secondes, et le before/after pour réparer.

Valide Angular 14.1+ : CanMatch et les guards fonctionnels (CanMatchFn, CanActivateFn avec inject()) sont arrivés ensemble en 14.1. CanLoad n'a été déprécié en faveur de canMatch qu'en 15.1. Testé sur Angular 17+.


TL;DR

canMatch canActivate
Phase Matching de l'URL (avant sélection de la route) Activation (après que la route est choisie)
Question posée Cette route correspond-elle à cette URL ? A-t-on le droit d'activer cette route ?
Si false La route est ignorée, le router essaie la suivante (fall-through) Navigation annulée (NavigationCancel)
Nombre d'exécutions Potentiellement plusieurs fois par navigation Une fois, sur la route matchée
Erreur visible Non : tu atterris silencieusement ailleurs Oui : NavigationCancel ou NavigationError
Bon usage Décisions structurelles synchrones Contrôle d'accès async (HTTP)

Règle d'or : un appel HTTP qui gate l'accès va dans canActivate ou un resolver, jamais dans canMatch.


Les deux guards ne tournent pas au même moment

C'est toute la clé, et c'est invisible tant que tes guards sont synchrones.

canMatch s'exécute pendant la phase de matching du Router, donc avant même que la route soit sélectionnée. Sa mission n'est pas de répondre "as-tu le droit ?" mais "est-ce que cette configuration de route correspond à cette URL ?". C'est l'héritier de CanLoad, conçu pour départager des routes candidates et décider quel chunk lazy charger.

Le détail qui change tout : si canMatch renvoie false, le Router ne bloque pas la navigation. Il considère que la route ne matche pas, la raye de la liste des candidats, et continue d'essayer les routes suivantes. Tes routes frères, puis le wildcard **. C'est un fall-through, exactement comme si le path ne correspondait pas.

canActivate, lui, s'exécute après le matching, pendant la phase d'activation, sur une route déjà sélectionnée. S'il renvoie false, la navigation est annulée proprement : tu obtiens un NavigationCancel avec le code GuardRejected. Et il peut renvoyer un UrlTree pour rediriger où tu veux.

Deux phases, deux sémantiques du false. L'un dit "ce n'est pas la bonne route", l'autre dit "tu n'as pas le droit d'entrer". Ce ne sont pas des synonymes.


Le code qui a l'air correct mais te trahit

Voilà le pattern qui casse. Un guard fonctionnel branché en canMatch, avec un appel HTTP pour décider de l'accès :

import { CanMatchFn } from '@angular/router';
import { inject } from '@angular/core';
import { map } from 'rxjs';

export const hasProjectAccess: CanMatchFn = () => {
  const api = inject(ProjectAccessApi);
  // Appel HTTP pour décider si on a accès au projet.
  return api.canAccess().pipe(map((res) => res.allowed));
};
import { Route } from '@angular/router';

export const routes: Route[] = [
  {
    path: 'project/:id',
    canMatch: [hasProjectAccess],
    loadComponent: () => import('./project.page'),
  },
  // Le filet qui te piège en silence :
  { path: '**', loadComponent: () => import('./not-found.page') },
];

En navigation in-app, ça passe. Quand l'utilisateur clique sur un lien vers /project/42, l'app tourne déjà depuis un moment : le token d'auth est en place, les interceptors sont configurés, et la réponse de canAccess() est souvent déjà en cache. L'HTTP renvoie { allowed: true }, la route matche, tout va bien.

En accès direct par URL, c'est le drame. Tu colles /project/42, tu fais Entrée. Le Router démarre sa toute première navigation au bootstrap, c'est-à-dire au moment le plus fragile du cycle de vie. Et là, plusieurs scénarios te tombent dessus :

  • Le token d'auth n'est pas encore hydraté, l'appel part sans header, le backend renvoie 401, et ton map mappe ça en allowed: false.
  • Une dépendance (un store, un APP_INITIALIZER) n'a pas fini de se résoudre, et ton guard renvoie false par défaut.

Dans ces cas, canMatch résout false. La route project/:id est rayée des candidats, le Router descend la liste, et atterrit sur le **. Tu te retrouves en 404. Pas d'erreur, pas de log, pas de NavigationError. Juste la mauvaise page. C'est ça, le piège silencieux : un false qui ressemble à un path qui ne correspond pas.

Attention à bien distinguer les deux modes d'échec, parce qu'ils ne se déboguent pas pareil. Si ton observable émet false, tu as le fall-through silencieux décrit ci-dessus. Mais si ton observable part en erreur (l'HTTP throw sans que tu le catchError), ce n'est pas un non-match : le Router remonte l'erreur et émet un NavigationError bien visible. Les deux te cassent la page en accès direct, mais seul le false le fait en silence. Dans les deux cas, le bon réflexe est le même : sortir la décision de canMatch.


La signature de diagnostic qui te fait gagner deux heures

C'est le point le plus utile à retenir, parce qu'il te dit instantanément si tu es face à ce bug.

Un canMatch qui échoue te jette silencieusement sur une autre route. Aucun event d'erreur. Si tu écoutes router.events, tu vois une navigation qui se termine en NavigationEnd vers /project/42... mais avec le composant NotFound activé. Tout a l'air "réussi", sauf que ce n'est pas la bonne route.

Un canActivate qui refuse, lui, émet un NavigationCancel avec un code explicite. C'est observable, loggable, débuggable :

import { inject } from '@angular/core';
import { NavigationCancel, Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

// À coller temporairement pour voir ce qui se passe.
const router = inject(Router);
router.events.pipe(takeUntilDestroyed()).subscribe((event) => {
  if (event instanceof NavigationCancel) {
    console.log('cancel', event.url, event.code);
  }
});

Si tu vois un NavigationCancel, c'est un canActivate ou un resolver. Si tu vois un NavigationEnd vers la bonne URL mais le mauvais composant, c'est un canMatch qui a fait fall-through. Cette seule distinction t'oriente direct vers la cause.


Le correctif : déplacer la décision dans canActivate

L'appel HTTP gate un accès, pas un matching. Sa place, c'est canActivate. Le guard tourne alors une seule fois, après le match, quand l'app est entièrement debout, et il refuse proprement avec une redirection :

import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { catchError, map, of } from 'rxjs';

export const hasProjectAccess: CanActivateFn = () => {
  const api = inject(ProjectAccessApi);
  const router = inject(Router);

  return api.canAccess().pipe(
    map((res) => (res.allowed ? true : router.parseUrl('/forbidden'))),
    // Une erreur HTTP renvoie une redirection nette, pas un cancel muet.
    catchError(() => of(router.parseUrl('/forbidden'))),
  );
};
import { Route } from '@angular/router';

export const routes: Route[] = [
  {
    path: 'project/:id',
    canActivate: [hasProjectAccess],
    loadComponent: () => import('./project.page'),
  },
  { path: 'forbidden', loadComponent: () => import('./forbidden.page') },
  { path: '**', loadComponent: () => import('./not-found.page') },
];

Maintenant, accès direct ou via lien, le comportement est identique : la route matche toujours (le path correspond), le guard s'exécute en phase d'activation, et soit il laisse passer, soit il redirige vers /forbidden avec un UrlTree. Plus de fall-through fantôme vers le 404, et une vraie page d'erreur d'accès au lieu d'un "page introuvable" trompeur.

Le retour d'un UrlTree depuis le guard, c'est ce qui déclenche le NavigationCancel avec le code Redirect puis une nouvelle navigation vers /forbidden. Propre, observable, testable.


Et le piège bonus : canMatch tourne plusieurs fois

Même si ton canMatch finissait par renvoyer la bonne valeur, le garder en async reste une mauvaise idée. Le matcher du Router explore les candidats de routes, et peut invoquer canMatch plusieurs fois pour une seule navigation. Un appel HTTP là-dedans, c'est potentiellement deux ou trois requêtes identiques par clic. Tu transformes une décision en rafale de requêtes, avec les conditions de course qui vont avec.

canActivate ne souffre pas de ça : il tourne une seule fois, sur la route déjà sélectionnée.


Quand canMatch est quand même le bon choix

Le but n'est pas de bannir canMatch, mais de l'utiliser pour ce à quoi il sert : des décisions structurelles, synchrones et bon marché, lues depuis un état déjà en mémoire.

import { CanMatchFn } from '@angular/router';
import { inject } from '@angular/core';

// Bon usage : choisir quelle variante de route charger selon un flag déjà connu.
export const betaEnabled: CanMatchFn = () => inject(FeatureFlags).has('beta-dashboard');
export const routes: Route[] = [
  { path: 'dashboard', canMatch: [betaEnabled], loadComponent: () => import('./dashboard-beta.page') },
  { path: 'dashboard', loadComponent: () => import('./dashboard.page') },
];

Là, le fall-through est une feature, pas un bug : si le flag beta est absent, le Router passe naturellement à la route dashboard classique. C'est exactement le cas d'usage de canMatch : faire coexister deux routes sur le même path et choisir laquelle s'applique. Synchrone, en mémoire, pas d'I/O.

Si tu as absolument besoin d'async dans un canMatch (par exemple une permission serveur qui décide quel module lazy charger), garantis au minimum une route de repli derrière, et assure-toi que la dépendance est prête au bootstrap via provideAppInitializer. Sinon tu reproduis le bug du chargement direct.


Récap actionnable

  • canMatch = phase de matching. Un false raye la route et fait fall-through vers la suivante. Pas d'annulation, pas d'erreur visible.
  • canActivate = phase d'activation. Un false annule la navigation (NavigationCancel), un UrlTree redirige proprement.
  • Le bug du chargement direct vient d'un canMatch async qui résout false au cold boot (auth pas prête, 401 mappé en false) et tombe sur le **. Un canMatch qui error est différent : il produit un NavigationError visible, pas un fall-through.
  • Signature de diagnostic : NavigationEnd vers la bonne URL mais mauvais composant = canMatch qui fall-through. NavigationCancel = canActivate qui refuse.
  • Un appel HTTP qui gate l'accès va dans canActivate (ou un resolver), jamais dans canMatch.
  • canMatch reste parfait pour départager deux routes sur le même path selon un flag synchrone en mémoire.

Le jour où tu arrêtes de voir canMatch et canActivate comme deux façons d'écrire le même guard, ce genre de bug "marche en cliquant, casse en collant l'URL" devient une lecture évidente du cycle de navigation, plus une enquête de deux heures.

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