Next.js course Β· Module 9: Integrations and Advanced Features
forbidden() and unauthorized() - New error handling functions
In this lesson10
Two people try to enter the administrative sector of Metropolis Quantum 2150. The first has no pass at all, the second has a guest pass. Both must be stopped, but each with a different message: the first should go to the registration desk, the second should learn that this sector is not for them. In HTTP that is the difference between status 401 and 403. You will learn the forbidden() and unauthorized() functions, introduced in Next.js 15.1, which simplify handling authorization and authentication errors.
Problem: Handling 401/403 errors by hand
Previously, handling authorization errors required manually creating a Response object with a status code in every place that checks the session:
1// Old, verbose approach
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}This pattern works in a Route Handler, but you cannot return a Response object from a page component, and the messages drift apart across the whole application.
Solution: forbidden() and unauthorized()
unauthorized() - 401 error
The unauthorized() function imported from next/navigation interrupts rendering of the segment, returns status 401 and shows the UI from the unauthorized.tsx file:
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(); // Throws a 401 error
10 }
11
12 return Response.json(session.user);
13}You do not write return unauthorized(), because the function throws and has the never return type. Thanks to that, TypeScript knows that after the condition session definitely exists.
forbidden() - 403 error
The forbidden() function works the same way, but it means "I know who you are, and you have no access". It renders the UI from the forbidden.tsx file:
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 - not logged in
10 }
11
12 if (session.user.role !== 'admin') {
13 forbidden(); // 403 - missing permissions
14 }
15
16 return Response.json(await getAllUsers());
17}The order of checks matters: identity first (401), then permissions (403). Both functions work in Server Components, Server Actions and Route Handlers.
Enabling the functions
In Next.js 16 both functions are still experimental and the docs do not recommend them for production yet. You enable them with the authInterrupts option:
1// next.config.js
2module.exports = {
3 experimental: {
4 authInterrupts: true,
5 },
6};Without this flag calling the functions ends in an error, so add it before you start experimenting.
Creating error pages
unauthorized.tsx - 401 page
The unauthorized.tsx file in the app directory is a regular React component that Next.js displays after unauthorized() is called:
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">Authorization required</h2>
8 <p className="text-gray-400 mb-8">
9 You must log in to access this 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 Log in
16 </a>
17 </div>
18 </div>
19 );
20}The most important part is the login link, because a user without a session needs a way forward, not just a message.
forbidden.tsx - 403 page
The 403 page has a different role: logging in will not help, so we offer a way back to the home page:
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">Access denied</h2>
8 <p className="text-gray-400 mb-8">
9 You do not have permission to view this 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 Back to home page
16 </a>
17 </div>
18 </div>
19 );
20}Next.js adds a noindex tag to both pages, so search engines do not index them.
Nested error pages
You can create error pages specific to segments. Next.js picks the nearest file up the directory tree:
1app/
2βββ unauthorized.tsx # Global 401 page
3βββ forbidden.tsx # Global 403 page
4βββ admin/
5β βββ unauthorized.tsx # 401 for /admin/*
6β βββ forbidden.tsx # 403 for /admin/*
7β βββ page.tsx
8βββ dashboard/
9 βββ forbidden.tsx # 403 for /dashboard/*
10 βββ page.tsxWith no unauthorized.tsx in dashboard/, the global 401 page from the app directory applies to that section.
Example - Admin forbidden.tsx
An error page can be an async Server Component and read the session to address the user by name:
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 Administrator area
13 </h1>
14 <p className="text-gray-400 mb-6">
15 Hi {session?.user?.name}! Unfortunately, your account does not have
16 administrator permissions. Contact the system administrator if you
17 believe this is a mistake.
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 User panel
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 Contact support
31 </a>
32 </div>
33 </div>
34 </div>
35 );
36}The user knows who they are logged in as and where they can go next: to their own panel or to support.
Usage in Server Components
In a page component you put the checks at the very beginning, before fetching data:
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>Administrator Panel</h1>
21 <AdminStats data={adminData} />
22 </div>
23 );
24}If the user lacks the admin:access permission, getAdminDashboard never runs. Just remember that forbidden() cannot be called in the root layout of the app. Also beware of try/catch around these functions: it swallows the interrupt and the error page will not show, unless you let it through with unstable_rethrow.
Usage in Middleware
Middleware runs before rendering, so it can reject a request the fastest. In Next.js 16 the middleware.ts file was renamed to proxy.ts and the function to proxy, but the logic stays the same:
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 // Paths requiring login
11 if (path.startsWith('/dashboard')) {
12 if (!token) {
13 // Redirect to the unauthorized page
14 return NextResponse.rewrite(new URL('/unauthorized', request.url));
15 }
16 }
17
18 // Paths requiring the admin role
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 shows the content of another address without changing the URL in the browser bar. Note: the unauthorized.tsx and forbidden.tsx files are not routes, so such a rewrite needs regular pages at /unauthorized and /forbidden. Middleware only checks the token, and you still repeat the real authorization close to the data.
Practical example - Role system
Instead of repeating the same conditions in every file, gather them in small helper functions. The Permission type lists the allowed permissions:
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 handles 401, while requirePermission and requireRole add 403. Each returns the session, so the calling code has immediate access to the user.
Usage in components:
1// app/admin/users/page.tsx
2import { requireAdmin } from '@/lib/auth-utils';
3
4export default async function AdminUsersPage() {
5 const session = await requireAdmin(); // Will throw 401 or 403 if needed
6
7 const users = await getAllUsers();
8
9 return <UserManagement users={users} currentAdmin={session.user} />;
10}One line replaces two conditions. The page stays readable, and the access rules live in a single file.
The same helper protects a Route Handler that deletes a 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'); // Will throw 401 or 403 if needed
9
10 await deletePost((await params).id);
11
12 return Response.json({ success: true });
13}The API endpoint and the page use identical rules, so there is no risk that one of them forgets the check.
Differences between unauthorized() and forbidden()
| Aspect | unauthorized() | forbidden() |
|---|---|---|
| HTTP code | 401 Unauthorized | 403 Forbidden |
| Meaning | No authentication | No authorization |
| When to use | User not logged in | User logged in, but without permissions |
| Error page | unauthorized.tsx | forbidden.tsx |
| Typical action | Redirect to login | Information about missing access |
One trap: if the check happens in a component inside Suspense, the response has already started streaming with status 200. The user will see the correct error page, but the HTTP code will not change. If you need a real 401 or 403, check before streaming, for example in proxy.
Summary
The forbidden() and unauthorized() functions in Next.js 15.1+:
- Simplify code - one line instead of creating a Response
- Standardize handling - consistent behavior across the application
- Support SSR - they work in Server Components, Server Actions and Route Handlers
- Are configurable - custom error pages per segment
My advice: keep access rules in a single helper file and always check permissions close to the data, not only in middleware. In the next lesson you will learn after(), which runs code after the response has been sent.
Remember: 401 says "introduce yourself", 403 says "I know you, but you are not getting in" - the guardians of the Metropolis never mix them up.
Code for this lesson: App.tsx
1import React, { useState } from 'react';
2
3// Create Article content type with title, content, slug fields
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: Implement auto-generating slug from title
20 // const generateSlug = (title: string) => title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
21
22 // TODO: Implement handleSave - add article to list
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: Title input with auto-slug generation */}
29 {/* TODO: Textarea content */}
30 {/* TODO: Display generated slug */}
31 {/* TODO: "Save" button */}
32 </form>
33 {/* TODO: List of saved articles */}
34 </div>
35 );
36}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What do the new forbidden() and unauthorized() functions introduced in Next.js 15 do?