NextAuth.js Authentication Series — Part 9 ~ Protecting Routes with Auth.js in Next.js
In the previous article, we learned how Auth.js manages JWT-based sessions and how authenticated user information can be accessed through the session.However,...

In the previous article, we learned how Auth.js manages JWT-based sessions and how authenticated user information can be accessed through the session.
However, simply creating a session isn't enough.
A real application must also prevent unauthenticated users from accessing private pages.
For example:
/dashboard
/profile
/settings
/admin
These pages should only be available to authenticated users.
In this article, we'll learn how to protect routes using Auth.js and Next.js App Router.
By the end of this article, we'll have a protected dashboard that authenticated users can access while unauthenticated users are redirected to the login page.
Public vs Protected Routes
Before implementing route protection, let's understand the difference.
Public Routes
Public routes can be accessed by anyone.
Examples:
/
/login
/register
/about
/contact
Protected Routes
Protected routes require authentication.
Examples:
/dashboard
/profile
/settings
/admin
The basic rule is:
Authenticated User
│
▼
Protected Route
│
▼
Allow
while:
Unauthenticated User
│
▼
Protected Route
│
▼
Redirect Login
How Route Protection Works
A simplified flow looks like this:
User Requests /dashboard
│
▼
Check Session
│
┌───┴───┐
│ │
Authenticated Not Authenticated
│ │
▼ ▼
Dashboard /login
The important part is that the authentication check should happen before sensitive content or actions are made available.
Step 1 — Export Auth.js Helpers
Modern Auth.js applications commonly centralize authentication configuration.
A typical setup can look like this:
auth.ts
For example:
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
// credentials configuration
}),
],
});
The important helper for route protection is:
auth
It allows us to retrieve the current authenticated session on the server.
Step 2 — Create a Protected Dashboard
Create:
app/dashboard/page.tsx
Because this is a Server Component by default, we can perform the authentication check on the server.
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return (
<main>
<h1>Dashboard</h1>
<p>
Welcome, {session.user.name}
</p>
<p>
Email: {session.user.email}
</p>
</main>
);
}
Now the dashboard is protected.
If the user is authenticated, the page is rendered.
If not, the user is redirected to:
/login
Step 3 — Why Server-Side Protection Matters
You might wonder:
"Can't we just hide the dashboard from unauthenticated users using React?"
For example:
if (!session) {
return null;
}
This isn't enough for sensitive applications.
Authentication checks should happen on the server for protected server-side resources.
Client-side checks can improve the user experience, but they shouldn't be your only security boundary.
Step 4 — Protecting Multiple Pages
Suppose your application contains:
/dashboard
/profile
/settings
/billing
You could perform the authentication check individually.
For example:
const session = await auth();
if (!session?.user) {
redirect("/login");
}
But repeating this code everywhere can become inconvenient.
A better approach is to protect groups of routes centrally where appropriate.
Step 5 — Using a Protected Route Group
Next.js Route Groups allow you to organize pages without changing their URLs.
For example:
app/
├── (public)/
│ ├── login/
│ ├── register/
│ └── about/
│
└── (protected)/
├── dashboard/
├── profile/
├── settings/
└── billing/
The (protected) folder doesn't appear in the URL.
So:
app/(protected)/dashboard/page.tsx
still becomes:
/dashboard
This makes larger applications easier to organize.
Step 6 — Protecting a Layout
Instead of checking authentication in every page, we can protect an entire layout.
Create:
app/(protected)/layout.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return <>{children}</>;
}
Now every page inside the (protected) group automatically requires authentication.
For example:
app/
└── (protected)/
├── layout.tsx
├── dashboard/
│ └── page.tsx
├── profile/
│ └── page.tsx
└── settings/
└── page.tsx
All of these pages are protected.
Step 7 — Protecting API Routes
Authentication shouldn't only protect UI pages.
API endpoints that return private information should also verify the current user.
For example:
app/api/profile/route.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export async function GET() {
const session = await auth();
if (!session?.user) {
return NextResponse.json(
{
message: "Unauthorized",
},
{
status: 401,
}
);
}
return NextResponse.json({
user: session.user,
});
}
Now an unauthenticated request receives:
401 Unauthorized
This is extremely important.
Protecting the page alone is not enough if the underlying API remains publicly accessible.
Page Protection vs API Protection
Think about authentication as two separate layers.
Authentication
│
┌──────────┴──────────┐
│ │
UI Routes API Routes
│ │
/dashboard /api/profile
│ │
▼ ▼
Protect Protect
Both layers should enforce the appropriate authorization rules.
Step 8 — Protecting Routes Before Rendering
For applications with many protected routes, Auth.js also provides an authorization mechanism that can run before a request reaches the relevant page.
A common pattern in Auth.js projects is:
auth.ts
exporting the authentication configuration and then using an Auth.js-aware request handler.
For example, a project may use:
export const { auth } = NextAuth({
providers: [
// providers
],
});
Then route protection can be centralized around authenticated requests.
However, the exact file convention depends on the Next.js version and project setup. In newer Next.js versions, the traditional middleware.ts convention is evolving toward a proxy.ts convention.
For that reason, it's useful to understand both approaches rather than blindly copying an older tutorial.
Middleware vs Server-Side Auth Checks
These approaches solve slightly different problems.
Server-side auth()
Useful when:
Protecting a page
Protecting a layout
Checking the current user
Loading user-specific server data
Example:
const session = await auth();
Request-level protection
Useful when:
Many routes share the same authentication requirement
You want to redirect unauthenticated requests early
You want centralized request authorization
The best approach depends on your application's structure.
Protecting Sensitive Data
Consider an endpoint:
/api/admin/users
Even if /admin is protected, you should still authorize the API request itself.
Never assume:
Protected Page = Protected API
They are separate boundaries.
Authentication vs Authorization
This distinction is extremely important.
Authentication
Answers:
Who are you?
For example:
User is logged in as:
john@example.com
Authorization
Answers:
What are you allowed to do?
For example:
john@example.com
Role: user
The user may be authenticated but still shouldn't access:
/admin
because they don't have the required permissions.
We'll implement this distinction properly in the next parts.
Common Mistakes
Only protecting the frontend
Hiding a button doesn't protect an API.
Trusting client-provided user IDs
Don't assume a user is allowed to access a resource simply because they sent a particular user ID.
Always derive identity from the authenticated session where appropriate.
Protecting pages but not APIs
Private APIs need their own authorization checks.
Confusing authentication with authorization
A logged-in user isn't automatically an administrator.
Example: Protected Dashboard
Our final dashboard flow looks like:
Request /dashboard
│
▼
auth()
│
┌───┴────┐
│ │
Session No Session
│ │
▼ ▼
Dashboard /login
And for an API:
Request /api/profile
│
▼
auth()
│
┌───┴────┐
│ │
Session No Session
│ │
▼ ▼
Data 401
This gives us two important protection layers.
Key Takeaways
In this article, we learned:
The difference between public and protected routes.
How to use
auth()for server-side authentication checks.How to protect a dashboard.
How to protect multiple routes using a layout.
Why API routes also need authentication.
The difference between authentication and authorization.
Why client-side protection alone isn't enough.
Why protected pages and protected APIs should be treated separately.
Why modern Next.js projects need to account for evolving request-level conventions.
Our application now has:
Registration
│
▼
MongoDB
│
▼
Credentials Login
│
▼
JWT Session
│
▼
Protected Routes
│
▼
Authenticated Application
What's Next?
We can now determine whether a user is authenticated.
But authentication alone isn't enough for most real-world applications.
Consider an application with:
Admin
User
Manager
Editor
A normal user shouldn't be able to access the admin dashboard just because they're logged in.
We need authorization.
In the next article, we'll implement Role-Based Authentication and Authorization using Auth.js and MongoDB.
We'll create roles such as:
admin
user
and learn how to protect specific pages and API endpoints based on the authenticated user's role.
Series Navigation
NextAuth.js Authentication Series
✅ Part 1 — Introduction to Authentication with NextAuth.js
✅ Part 2 — Installing & Configuring NextAuth.js
✅ Part 3 — Google OAuth Authentication
✅ Part 4 — Credentials Provider with Email & Password
✅ Part 5 — Integrating MongoDB with Credentials Authentication
✅ Part 6 — Secure Password Authentication with bcrypt
✅ Part 7 — Building a User Registration System
✅ Part 8 — JWT & Session Management
✅ Part 9 — Protecting Routes with Auth.js ← You are here


