DEV Community

Cover image for Unauthorized and Forbidden Pages in Next.js 15 ⚠️
Ali Samir
Ali Samir

Posted on

Unauthorized and Forbidden Pages in Next.js 15 ⚠️

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
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 dont have permission to access this page.
      </p>
      <Link href="/" className="mt-6 text-blue-500 hover:underline">
        Return to Home
      </Link>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
mahdijazini profile image
Mahdi Jazini

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!