Chapitre 11.5 — Pièges Next.js
⏱️ TL;DR — Next.js ne rend pas ton appli inaccessible, mais certains réflexes courants créent des trous :
<html>sanslang,titlequi ne change pas d’une page à l’autre,altoublié, contenu injecté viadangerouslySetInnerHTML,<div onClick>au lieu de<button>, contenu dynamique non annoncé. Ce chapitre les rassemble en une checklist Next.js a11y à passer avant chaque mise en ligne.
🎯 Objectifs
- Repérer les pièges d’accessibilité récurrents en Next.js.
- Corriger
lang,titleet métadonnées par page. - Éviter les faux éléments interactifs et le contenu non annoncé.
- Disposer d’une checklist Next.js prête à l’emploi.
Piège 1 — lang manquant sur <html>
Sans lang, le lecteur d’écran ne sait pas dans quelle langue prononcer la page (voir langue & lisibilité). En App Router, c’est dans le layout racine :
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="fr">
<body>{children}</body>
</html>
)
}Piège 2 — Le title de page qui ne change pas
Chaque page doit avoir un titre unique et descriptif : c’est la première chose qu’annonce un lecteur d’écran au chargement, et c’est essentiel au changement de route en SPA (voir le problème SPA). En App Router, via l’API metadata :
// app/cours/[slug]/page.tsx
export async function generateMetadata({ params }) {
const cours = await getCours(params.slug)
return { title: `${cours.titre} — A11yLearn` }
}Piège 3 — <div onClick> au lieu de <button>
Le piège le plus courant, et pas propre à Next : un élément cliquable qui n’est ni focusable ni annoncé.
// ❌ Ni focusable, ni annoncé « bouton », ni activable au clavier
<div onClick={ouvrir}>Ouvrir le menu</div>
// ✅ Rôle, focus et clavier natifs
<button type="button" onClick={ouvrir}>Ouvrir le menu</button>⚠️ Piège —
eslint-plugin-jsx-a11yattrape une partie de ces cas (<div onClick>sans rôle/clavier), mais pas tous. Ne compte pas sur le seul linter : garde le réflexe « action →<button>, navigation →<a>» (3.4).
Piège 4 — dangerouslySetInnerHTML
Injecter du HTML brut (contenu d’un CMS, markdown rendu) court-circuite le JSX et peut introduire du HTML non sémantique ou inaccessible (titres sautés, images sans alt, <div> cliquables). Si tu dois l’utiliser, contrôle et nettoie la source, et vérifie le rendu au lecteur d’écran. Pour A11yLearn, le contenu des cours vient de Moodle : on s’assure que le HTML importé respecte les titres et les alt (voir Partie 12).
Piège 5 — Contenu dynamique non annoncé
Un filtre qui met à jour une liste, un panier qui change, un message de chargement : visuellement évidents, muets pour le lecteur d’écran si rien ne l’annonce. Solution : une live region (6.4) pour annoncer « 12 résultats », « ajouté », etc. En React, un composant annonceur réutilisable règle la plupart des cas.
Piège 6 — autoFocus et hydration
autoFocus peut déplacer le focus de façon surprenante (surtout au chargement) et interférer avec la restauration de scroll ; utilise plutôt un focus explicite et contextuel géré au bon moment (gérer le focus). Attention aussi aux différences serveur/client (hydration) qui peuvent réinitialiser un focus posé trop tôt.
La checklist Next.js a11y
[ ] <html lang="fr"> dans le layout racine
[ ] title unique et descriptif par page (metadata)
[ ] focus déplacé + page annoncée au changement de route
[ ] tout élément cliquable est <button> ou <a> (jamais <div onClick>)
[ ] next/image : alt pertinent ou alt="" si décoratif
[ ] next/link pour la navigation interne (vrai <a>)
[ ] contenu dynamique (filtres, toasts) annoncé via live region
[ ] dangerouslySetInnerHTML : source contrôlée et vérifiée
[ ] eslint-plugin-jsx-a11y actif en CI
[ ] test clavier + lecteur d'écran sur les parcours clés🧭 Sur A11yLearn — Avant chaque déploiement d’A11yLearn, on passe cette checklist :
lang="fr", untitlepar cours, la modale Radix testée, lesaltdes images de leçon, la live region qui annonce « progression enregistrée ». Dix minutes qui évitent l’essentiel des régressions.
💡 Réflexe — Beaucoup de ces pièges se préviennent une fois pour toutes :
langdans le layout,eslint-plugin-jsx-a11yen CI (Partie 14), et un<Button>/<Link>maison dans ton design system. L’accessibilité qui tient, c’est celle qu’on encode dans l’outillage plutôt que de la refaire à la main à chaque page.
✏️ Exercices
Exercice 1 — Trouve les trois bugs. Repère les problèmes d’accessibilité dans ce composant Next.
export default function Carte({ cours }) {
return (
<div onClick={() => router.push(`/cours/${cours.slug}`)}>
<img src={cours.image} />
<h4>{cours.titre}</h4>
</div>
)
}✅ Solution
(1) La <div onClick> navigue mais n’est ni focusable ni annoncée : utiliser next/link (<Link> → <a>). (2) L’<img> n’a pas de alt : ajouter un alt descriptif ou alt="". (3) Un <h4> isolé peut casser la hiérarchie des titres selon le contexte (vérifier qu’il suit un <h3>). Version corrigée : un <Link> enveloppant, un <Image> avec alt, et un niveau de titre cohérent avec la page.
Exercice 2 — Annoncer un filtre. Une liste de cours se met à jour quand on change un filtre, mais le lecteur d’écran ne dit rien. Comment corriger ?
✅ Solution
Ajouter une live region (role="status" / aria-live="polite") qui annonce le résultat après filtrage, par exemple « 8 cours trouvés ». Le focus ne doit pas être volé : la région annonce discrètement pendant que l’utilisateur reste sur le filtre. C’est le pattern de la Partie 6.4.
🧠 Quiz de révision
1. Où déclarer la langue de la page en App Router ?
Sur l’élément <html lang="fr"> du layout racine (app/layout.tsx). Sans lang, le lecteur d’écran ne sait pas comment prononcer le contenu.
2. Pourquoi le title par page compte-t-il particulièrement en SPA ?
title par page compte-t-il particulièrement en SPA ?Parce qu’au changement de route côté client, le title est un des rares repères annoncés (avec la gestion du focus) pour signaler qu’on a changé de page. Un title unique et descriptif par page, via l’API metadata, est donc essentiel.
3. Quel est le risque de dangerouslySetInnerHTML ?
dangerouslySetInnerHTML ?Injecter du HTML brut non contrôlé peut introduire du contenu non sémantique ou inaccessible (titres incohérents, images sans alt, faux boutons) et court-circuiter le JSX. Il faut contrôler la source et vérifier le rendu.
4. Comment rendre <div onClick> accessible ?
<div onClick> accessible ?En le remplaçant par l’élément natif adapté : <button> pour une action, <a>/<Link> pour une navigation. Ils apportent rôle, focus et clavier gratuitement. Reconstruire tout ça sur une <div> est fastidieux et fragile.
5. Quelle est la meilleure façon de prévenir durablement ces pièges ?
Les encoder dans l’outillage : lang dans le layout, eslint-plugin-jsx-a11y en CI, des composants <Button>/<Link> maison dans le design system, une checklist de déploiement. L’a11y qui tient est celle qu’on n’a pas à refaire à la main à chaque page.
Partie suivante : Moodle & plateformes de formation — l’accessibilité côté LMS et contenu pédagogique.