Cours Next.js · Module 9: Intégrations et fonctions avancées
forbidden() et unauthorized() - Nouvelles fonctions de gestion des erreurs
Dans cette leçon10
Deux personnes tentent d'entrer dans le secteur administratif de la Métropole Quantum 2150. La première n'a aucun laissez-passer, la seconde a un laissez-passer d'invité. Toutes deux doivent être arrêtées, mais chacune avec un message différent : la première doit aller au guichet d'enregistrement, la seconde doit apprendre que ce secteur ne lui est pas destiné. En HTTP, c'est la différence entre le code 401 et le code 403. Tu vas découvrir les fonctions forbidden() et unauthorized(), introduites dans Next.js 15.1, qui simplifient la gestion des erreurs d'autorisation et d'authentification.
Problème : gérer les erreurs 401/403 à la main
Auparavant, gérer les erreurs d'autorisation exigeait de créer manuellement un objet Response avec un code de statut à chaque endroit qui vérifie la session :
1// Ancienne approche verbeuse
2export async function GET(request: Request) {
3 const session = await getSession();
4
5 if (!session) {
6 return new Response('Unauthorized', { status: 401 });
7 }
8
9 if (!session.user.isAdmin) {
10 return new Response('Forbidden', { status: 403 });
11 }
12
13 return Response.json(await getAdminData());
14}Ce pattern fonctionne dans un Route Handler, mais tu ne peux pas renvoyer un objet Response depuis un composant de page, et les messages divergent dans toute l'application.
Solution : forbidden() et unauthorized()
unauthorized() - Erreur 401
La fonction unauthorized(), importée depuis next/navigation, interrompt le rendu du segment, renvoie le statut 401 et affiche l'interface du fichier unauthorized.tsx :
1// app/api/profile/route.ts
2import { unauthorized } from 'next/navigation';
3import { getSession } from '@/lib/auth';
4
5export async function GET(request: Request) {
6 const session = await getSession();
7
8 if (!session) {
9 unauthorized(); // Lève une erreur 401
10 }
11
12 return Response.json(session.user);
13}Tu n'écris pas return unauthorized(), car la fonction lève une exception et a le type de retour never. Grâce à cela, TypeScript sait qu'après la condition, session existe forcément.
forbidden() - Erreur 403
La fonction forbidden() fonctionne de la même façon, mais elle signifie "je sais qui tu es, et tu n'as pas accès". Elle affiche l'interface du fichier forbidden.tsx :
1// app/api/admin/users/route.ts
2import { forbidden, unauthorized } from 'next/navigation';
3import { getSession } from '@/lib/auth';
4
5export async function GET(request: Request) {
6 const session = await getSession();
7
8 if (!session) {
9 unauthorized(); // 401 - non connecté
10 }
11
12 if (session.user.role !== 'admin') {
13 forbidden(); // 403 - permissions manquantes
14 }
15
16 return Response.json(await getAllUsers());
17}L'ordre des vérifications compte : d'abord l'identité (401), ensuite les permissions (403). Les deux fonctions marchent dans les Server Components, les Server Actions et les Route Handlers.
Activer les fonctions
Dans Next.js 16, les deux fonctions sont toujours expérimentales et la documentation ne les recommande pas encore en production. Tu les actives avec l'option authInterrupts :
1// next.config.js
2module.exports = {
3 experimental: {
4 authInterrupts: true,
5 },
6};Sans ce flag, l'appel des fonctions se termine par une erreur, donc ajoute-le avant de commencer à expérimenter.
Créer les pages d'erreur
unauthorized.tsx - Page 401
Le fichier unauthorized.tsx dans le dossier app est un composant React ordinaire que Next.js affiche après l'appel à unauthorized() :
1// app/unauthorized.tsx
2export default function UnauthorizedPage() {
3 return (
4 <div className="min-h-screen flex items-center justify-center bg-gray-900">
5 <div className="text-center">
6 <h1 className="text-6xl font-bold text-red-500 mb-4">401</h1>
7 <h2 className="text-2xl text-white mb-4">Autorisation requise</h2>
8 <p className="text-gray-400 mb-8">
9 Tu dois te connecter pour accéder à cette page.
10 </p>
11 <a
12 href="/login"
13 className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
14 >
15 Se connecter
16 </a>
17 </div>
18 </div>
19 );
20}Le plus important ici est le lien de connexion, car un utilisateur sans session a besoin d'une piste pour avancer, pas seulement d'un message.
forbidden.tsx - Page 403
La page 403 a un autre rôle : se connecter ne servira à rien, donc on propose un retour à la page d'accueil :
1// app/forbidden.tsx
2export default function ForbiddenPage() {
3 return (
4 <div className="min-h-screen flex items-center justify-center bg-gray-900">
5 <div className="text-center">
6 <h1 className="text-6xl font-bold text-yellow-500 mb-4">403</h1>
7 <h2 className="text-2xl text-white mb-4">Accès refusé</h2>
8 <p className="text-gray-400 mb-8">
9 Tu n'as pas la permission de voir cette page.
10 </p>
11 <a
12 href="/"
13 className="px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700"
14 >
15 Retour à la page d'accueil
16 </a>
17 </div>
18 </div>
19 );
20}Next.js ajoute une balise noindex aux deux pages, donc les moteurs de recherche ne les indexent pas.
Pages d'erreur imbriquées
Tu peux créer des pages d'erreur propres à chaque segment. Next.js choisit le fichier le plus proche en remontant l'arborescence :
1app/
2├── unauthorized.tsx # Page 401 globale
3├── forbidden.tsx # Page 403 globale
4├── admin/
5│ ├── unauthorized.tsx # 401 pour /admin/*
6│ ├── forbidden.tsx # 403 pour /admin/*
7│ └── page.tsx
8└── dashboard/
9 ├── forbidden.tsx # 403 pour /dashboard/*
10 └── page.tsxSans unauthorized.tsx dans dashboard/, c'est la page 401 globale du dossier app qui s'applique à cette section.
Exemple - forbidden.tsx de l'admin
Une page d'erreur peut être un Server Component asynchrone et lire la session pour s'adresser à l'utilisateur par son nom :
1// app/admin/forbidden.tsx
2import { getSession } from '@/lib/auth';
3
4export default async function AdminForbiddenPage() {
5 const session = await getSession();
6
7 return (
8 <div className="min-h-screen flex items-center justify-center bg-slate-900">
9 <div className="max-w-md text-center p-8 bg-slate-800 rounded-xl">
10 <div className="text-6xl mb-4">◆</div>
11 <h1 className="text-2xl font-bold text-white mb-4">
12 Zone administrateur
13 </h1>
14 <p className="text-gray-400 mb-6">
15 Salut {session?.user?.name} ! Malheureusement, ton compte ne dispose pas
16 des permissions administrateur. Contacte l'administrateur système si tu
17 penses qu'il s'agit d'une erreur.
18 </p>
19 <div className="flex gap-4 justify-center">
20 <a
21 href="/dashboard"
22 className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
23 >
24 Espace utilisateur
25 </a>
26 <a
27 href="/support"
28 className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
29 >
30 Contacter le support
31 </a>
32 </div>
33 </div>
34 </div>
35 );
36}L'utilisateur sait sous quel compte il est connecté et où aller ensuite : vers son propre espace ou vers le support.
Utilisation dans les Server Components
Dans un composant de page, tu places les vérifications tout au début, avant de récupérer les données :
1// app/admin/page.tsx
2import { forbidden, unauthorized } from 'next/navigation';
3import { getSession } from '@/lib/auth';
4
5export default async function AdminPage() {
6 const session = await getSession();
7
8 if (!session) {
9 unauthorized();
10 }
11
12 if (!session.user.permissions.includes('admin:access')) {
13 forbidden();
14 }
15
16 const adminData = await getAdminDashboard();
17
18 return (
19 <div className="admin-dashboard">
20 <h1>Panneau administrateur</h1>
21 <AdminStats data={adminData} />
22 </div>
23 );
24}Si l'utilisateur n'a pas la permission admin:access, getAdminDashboard ne s'exécute jamais. Souviens-toi seulement que forbidden() ne peut pas être appelée dans le layout racine de l'application (root layout). Méfie-toi aussi d'un try/catch autour de ces fonctions : il avale l'interruption et la page d'erreur ne s'affiche pas, sauf si tu la laisses passer avec unstable_rethrow.
Utilisation dans le Middleware
Le Middleware s'exécute avant le rendu, il peut donc rejeter une requête le plus vite possible. Dans Next.js 16, le fichier middleware.ts a été renommé en proxy.ts et la fonction en proxy, mais la logique reste la même :
1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4import { getToken } from 'next-auth/jwt';
5
6export async function middleware(request: NextRequest) {
7 const token = await getToken({ req: request });
8 const path = request.nextUrl.pathname;
9
10 // Chemins nécessitant une connexion
11 if (path.startsWith('/dashboard')) {
12 if (!token) {
13 // Redirige vers la page unauthorized
14 return NextResponse.rewrite(new URL('/unauthorized', request.url));
15 }
16 }
17
18 // Chemins nécessitant le rôle admin
19 if (path.startsWith('/admin')) {
20 if (!token) {
21 return NextResponse.rewrite(new URL('/unauthorized', request.url));
22 }
23
24 if (token.role !== 'admin') {
25 return NextResponse.rewrite(new URL('/forbidden', request.url));
26 }
27 }
28
29 return NextResponse.next();
30}
31
32export const config = {
33 matcher: ['/dashboard/:path*', '/admin/:path*'],
34};NextResponse.rewrite affiche le contenu d'une autre adresse sans changer l'URL dans la barre du navigateur. Attention : les fichiers unauthorized.tsx et forbidden.tsx ne sont pas des routes, donc un tel rewrite nécessite de vraies pages aux adresses /unauthorized et /forbidden. Le Middleware ne vérifie que le jeton, et tu répètes quand même la vraie autorisation près des données.
Exemple pratique - Système de rôles
Au lieu de répéter les mêmes conditions dans chaque fichier, regroupe-les dans de petites fonctions utilitaires. Le type Permission énumère les permissions autorisées :
1// lib/auth-utils.ts
2import { forbidden, unauthorized } from 'next/navigation';
3import { getSession } from '@/lib/auth';
4
5type Permission = 'read' | 'write' | 'delete' | 'admin';
6
7export async function requireAuth() {
8 const session = await getSession();
9
10 if (!session) {
11 unauthorized();
12 }
13
14 return session;
15}
16
17export async function requirePermission(permission: Permission) {
18 const session = await requireAuth();
19
20 if (!session.user.permissions.includes(permission)) {
21 forbidden();
22 }
23
24 return session;
25}
26
27export async function requireRole(role: string) {
28 const session = await requireAuth();
29
30 if (session.user.role !== role) {
31 forbidden();
32 }
33
34 return session;
35}
36
37export async function requireAdmin() {
38 return requireRole('admin');
39}requireAuth gère le 401, tandis que requirePermission et requireRole ajoutent le 403. Chacune renvoie la session, donc le code appelant a directement accès à l'utilisateur.
Utilisation dans les composants :
1// app/admin/users/page.tsx
2import { requireAdmin } from '@/lib/auth-utils';
3
4export default async function AdminUsersPage() {
5 const session = await requireAdmin(); // Lèvera 401 ou 403 si nécessaire
6
7 const users = await getAllUsers();
8
9 return <UserManagement users={users} currentAdmin={session.user} />;
10}Une seule ligne remplace deux conditions. La page reste lisible, et les règles d'accès vivent dans un seul fichier.
Le même utilitaire protège un Route Handler qui supprime un post :
1// app/api/posts/[id]/route.ts
2import { requirePermission } from '@/lib/auth-utils';
3
4export async function DELETE(
5 request: Request,
6 { params }: { params: Promise<{ id: string }> }
7) {
8 await requirePermission('delete'); // Lèvera 401 ou 403 si nécessaire
9
10 await deletePost((await params).id);
11
12 return Response.json({ success: true });
13}L'endpoint d'API et la page utilisent des règles identiques, donc aucun risque que l'un d'eux oublie la vérification.
Différences entre unauthorized() et forbidden()
| Aspect | unauthorized() | forbidden() |
|---|---|---|
| Code HTTP | 401 Unauthorized | 403 Forbidden |
| Signification | Pas d'authentification | Pas d'autorisation |
| Quand l'utiliser | Utilisateur non connecté | Utilisateur connecté, mais sans permissions |
| Page d'erreur | unauthorized.tsx | forbidden.tsx |
| Action typique | Redirection vers la connexion | Information sur l'accès refusé |
Un piège : si la vérification a lieu dans un composant à l'intérieur de Suspense, la réponse a déjà commencé à être streamée avec le statut 200. L'utilisateur verra la bonne page d'erreur, mais le code HTTP ne changera pas. Si tu as besoin d'un vrai 401 ou 403, vérifie avant le streaming, par exemple dans proxy.
Résumé
Les fonctions forbidden() et unauthorized() dans Next.js 15.1+ :
- Simplifient le code - une ligne au lieu de créer une Response
- Standardisent la gestion - un comportement cohérent dans toute l'application
- Prennent en charge le SSR - elles fonctionnent dans les Server Components, les Server Actions et les Route Handlers
- Sont configurables - des pages d'erreur personnalisées par segment
Mon conseil : garde les règles d'accès dans un seul fichier utilitaire et vérifie toujours les permissions près des données, pas seulement dans le middleware. Dans la leçon suivante, tu découvriras after(), qui exécute du code une fois la réponse envoyée.
Retiens : 401 dit "présente-toi", 403 dit "je te connais, mais tu n'entreras pas" - les gardiens de la Métropole ne les confondent jamais.
Code de cette leçon : App.tsx
1import React, { useState } from 'react';
2
3// Crée le type de contenu Article avec les champs title, content, slug
4
5interface ArticleContentType {
6 title: string;
7 content: string;
8 slug: string;
9 author?: string;
10 publishedAt?: string;
11}
12
13export default function ContentTypeBuilder() {
14 const [article, setArticle] = useState<ArticleContentType>({
15 title: '', content: '', slug: '',
16 });
17 const [articles, setArticles] = useState<ArticleContentType[]>([]);
18
19 // TODO : Implémente la génération automatique du slug à partir du titre
20 // const generateSlug = (title: string) => title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
21
22 // TODO : Implémente handleSave - ajoute l'article à la liste
23
24 return (
25 <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
26 <h1 style={{ color: '#64ffda' }}>Article Content Type</h1>
27 <form style={{ maxWidth: 500 }}>
28 {/* TODO : Champ titre avec génération auto du slug */}
29 {/* TODO : Zone de texte content */}
30 {/* TODO : Affiche le slug généré */}
31 {/* TODO : Bouton « Enregistrer » */}
32 </form>
33 {/* TODO : Liste des articles enregistrés */}
34 </div>
35 );
36}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Que font les nouvelles fonctions forbidden() et unauthorized() introduites dans Next.js 15 ?