Next.js 15 brings powerful tools for building modern web applications, including enhanced routing, server-side rendering, and middleware capabilities.
Managing access control—such as handling unauthorized (401) and forbidden (403) scenarios—becomes critical as applications grow.
In this article, we’ll explore implementing unauthorized and forbidden pages in Next.js 15 using TypeScript. We'll leverage middleware, server components, and client-side logic. We’ll provide practical examples, explain best practices, and ensure type safety throughout.
By the end, you’ll have a robust solution for restricting access, redirecting users, and displaying custom error pages—all while keeping your codebase clean and maintainable.
Understanding 401 and 403 in Web Development
Before diving into the code, let’s clarify the concepts:
401 Unauthorized: Indicates that a user hasn’t provided valid authentication credentials (e.g., they’re not logged in). The solution is typically a redirect to a login page.
403 Forbidden: Indicates that the user is authenticated but lacks permission to access a resource. This might trigger a custom "Access Denied" page.
In Next.js 15, we can handle these scenarios by combining middleware for route protection, server-side logic for dynamic checks, and client-side UI for user feedback.
Setting Up the Project
Let’s assume you’ve initialized a Next.js 15 project with TypeScript. If not, create one with:
npx create-next-app@latest my-app --typescript
cd my-app
npm run dev
We’ll use the App Router (introduced in Next.js 13 and refined in 15), which is now the default routing system. Our goal is to protect certain routes, redirect unauthorized users to a login page, and show a forbidden page for authenticated but unauthorized users.
Step 1: Middleware for Authentication Checks
Next.js 15’s middleware runs at the edge and is perfect for handling authentication before a request reaches your pages. Let’s create a middleware.ts
file at the root of your project.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
// Simulated user session (in practice, fetch from auth provider)
const getSession = async (request: NextRequest): Promise<{ user: { id: string; role: string } | null }> => {
const token = request.cookies.get("auth_token")?.value;
if (!token) return { user: null };
// Simulate decoding token (replace with actual logic, e.g., JWT verification)
return { user: { id: "user123", role: "user" } };
};
export async function middleware(request: NextRequest) {
const session = await getSession(request);
const { pathname } = request.nextUrl;
// Protect routes starting with /dashboard
if (pathname.startsWith("/dashboard")) {
if (!session.user) {
// 401: Redirect to login if not authenticated
return NextResponse.redirect(new URL("/login", request.url));
}
// 403: Check role-based access (e.g., only admins allowed)
if (pathname.startsWith("/dashboard/admin") && session.user.role !== "admin") {
return NextResponse.redirect(new URL("/forbidden", request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"], // Apply middleware to /dashboard and its subroutes
};
Explanation:
Type Safety: We define a
getSession
function with a typed return value using TypeScript. In a real app, this would integrate with an authentication provider like NextAuth.js or Clerk.Middleware Logic: If no user is found (
!session.user
), we redirect to/login
(401). If the user lacks the "admin" role for/dashboard/admin
, we redirect to/forbidden
(403).Matcher: The
config.matcher
ensures middleware only runs for/dashboard
routes.
Step 2: Creating the Login Page (401 Handling)
Let’s create a login page at app/login/page.tsx
to handle unauthenticated users.
// app/login/page.tsx
import { redirect } from "next/navigation";
export default function LoginPage() {
// Simulate checking if user is already logged in
const isAuthenticated = false; // Replace with real auth check
if (isAuthenticated) {
redirect("/dashboard");
}
const handleLogin = async (formData: FormData) => {
"use server"; // Server directive for form actions
const email = formData.get("email") as string;
const password = formData.get("password") as string;
// Simulate login (replace with actual auth logic)
if (email && password) {
redirect("/dashboard");
}
};
return (
<div className="min-h-screen flex items-center justify-center">
<form action={handleLogin} className="space-y-4">
<h1 className="text-2xl">Login</h1>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
className="border p-2 w-full"
required
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
className="border p-2 w-full"
required
/>
</div>
<button type="submit" className="bg-blue-500 text-white p-2 rounded">
Log In
</button>
</form>
</div>
);
}
Explanation:
Server Actions: The
handleLogin
function uses Next.js 15’s server directives for form handling, keeping logic on the server.Redirect: If login succeeds, users are redirected to
/dashboard
. In a real app, you’d set a session cookie or token here.
Step 3: Creating the Forbidden Page (403 Handling)
Now, let’s create a app/forbidden/page.tsx
page for forbidden access scenarios.
// app/forbidden/page.tsx
import Link from "next/link";
export default function ForbiddenPage() {
return (
<div className="min-h-screen flex flex-col items-center justify-center text-center">
<h1 className="text-4xl font-bold text-red-600">403 - Forbidden</h1>
<p className="mt-4 text-lg">
You don’t have permission to access this page.
</p>
<Link href="/" className="mt-6 text-blue-500 hover:underline">
Return to Home
</Link>
</div>
);
}
Explanation:
Simple UI: A clean, user-friendly page informs the user of the 403 error and provides a way back home.
Static: Since this is a straightforward page, it’s statically rendered by default in Next.js 15.
Step 4: Protected Dashboard Pages
Let’s create a basic dashboard at app/dashboard/page.tsx
and an admin-only page at app/dashboard/admin/page.tsx
.
// app/dashboard/page.tsx
import { cookies } from "next/headers";
export default async function DashboardPage() {
const cookieStore = cookies();
const user = cookieStore.get("auth_token") ? { id: "user123", role: "user" } : null;
if (!user) {
throw new Error("This should never happen due to middleware");
}
return (
<div className="min-h-screen p-8">
<h1 className="text-3xl">Welcome to Your Dashboard</h1>
<p>Role: {user.role}</p>
</div>
);
}
// app/dashboard/admin/page.tsx
import { cookies } from "next/headers";
export default async function AdminPage() {
const cookieStore = cookies();
const user = cookieStore.get("auth_token") ? { id: "user123", role: "user" } : null;
if (!user || user.role !== "admin") {
throw new Error("This should never happen due to middleware");
}
return (
<div className="min-h-screen p-8">
<h1 className="text-3xl">Admin Dashboard</h1>
<p>Only admins can see this!</p>
</div>
);
}
Explanation:
Server Components: These pages use Next.js 15’s server components by default, fetching cookies directly on the server.
Redundancy Check: The middleware already handles redirects, so these errors are fallback assertions.
Best Practices:
1- Type Safety: Always define interfaces for user data (e.g., { id: string; role: string }
).
2- Centralized Auth: Use a library like NextAuth.js for real-world apps to manage sessions and tokens.
3- Error Boundaries: Wrap pages in error boundaries for unexpected failures.
4- SEO: Add metadata to your pages using Next.js 15’s generateMetadata
function.
Testing the Flow
Visit
/dashboard
without logging in → Redirected to/login
.Log in as a non-admin user → Access
/dashboard
but redirected from/dashboard/admin
to/forbidden
.Log in as an admin → Access both
/dashboard
and/dashboard/admin
.
Conclusion
Handling unauthorized (401) and forbidden (403) scenarios in Next.js 15 with TypeScript is straightforward yet powerful.
Middleware provides global route protection, server components ensure secure data fetching, and custom pages offer a seamless user experience.
By combining these tools, you can build secure, scalable applications with confidence.
Experiment with this setup, integrate a real authentication system and adapt it to your project’s needs. Next.js 15’s flexibility makes it an excellent choice for modern web development—especially when paired with TypeScript’s type safety.
🌐 Connect With Me On:
📍 LinkedIn
📍 X (Twitter)
📍 Telegram
📍 Instagram
Happy Coding!
Top comments (1)
Thanks for the great article! Your explanations on access management in Next.js 15 with TypeScript and using middleware were really practical. The examples were excellent too. Just for real-world projects, I’d suggest using NextAuth.js or Clerk for added security.
Thanks again!