Protected Routes
Protected routes are pages that require authentication. Think of them as the bouncer at a club—no wristband, no entry. Get this wrong and anyone can access private data.
Why Route Protection Matters
Without protection, anyone who guesses a URL can access private content. /dashboard should only show your data, not everyone's.
Without Protection
- • Anyone can view /dashboard
- • Users see each other's data
- • API routes expose everything
With Protection
- • Redirects to login if not signed in
- • Each user sees only their data
- • API routes verify the session
Server-Side Protection (Recommended)
The safest approach: check authentication on the server before the page even renders. No flash of unauthorized content.
// app/dashboard/page.tsx (Server Component)
import
{ auth }
from
"@/auth"
;
import
{ redirect }
from
"next/navigation"
;
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/api/auth/signin");
}
// User is authenticated - render the page
return (
<div>
<h1>Welcome, {session.user.name}</h1>
</div>
);
}
Why Server-Side?
The check happens before any HTML is sent. No JavaScript needed, no flash of content, works without JS.
Middleware Protection (For Multiple Routes)
When you have many protected routes, use middleware to protect them all at once. Runs before every request.
// middleware.ts (in project root)
import
{ auth }
from
"@/auth"
;
import
{ NextResponse }
from
"next/server"
;
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isProtected = req.nextUrl.pathname.startsWith("/dashboard");
if (isProtected && !isLoggedIn) {
return NextResponse.redirect(new URL("/api/auth/signin", req.url));
}
});
export const config = {
matcher: ["/dashboard/:path*", "/account/:path*"],
};
The matcher
Only run middleware on matching routes. :path* means "and all sub-paths".
Client-Side Protection (For Components)
For client components that need session data, use the useSession hook. Best for UI adjustments, not for security.
// components/ProtectedContent.tsx
"use client";
import
{ useSession }
from
"next-auth/react"
;
import
{ useRouter }
from
"next/navigation"
;
export function ProtectedContent() {
const { data: session, status } = useSession();
const router = useRouter();
if (status === "loading") {
return <div>Loading...</div>;
}
if (!session) {
router.push("/api/auth/signin");
return null;
}
return <div>Welcome, {session.user?.name}</div>;
}
Warning: Flash of Content
Client-side checks happen after the page loads. Users may briefly see protected content before redirect. Always combine with server-side checks for sensitive pages.
Protecting API Routes
Critical: API routes must check authentication too. Pages might redirect, but API routes must return 401.
// app/api/user/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(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Fetch user-specific data
const userData = await getUserData(session.user.id);
return NextResponse.json(userData);
}
Role-Based Access (Admin Pages)
Some pages need more than authentication—they need authorization. Only admins should access admin pages.
// app/admin/page.tsx
import
{ auth }
from
"@/auth"
;
import
{ redirect }
from
"next/navigation"
;
import
{ isAdminUser }
from
"@/lib/config/admin"
;
export default async function AdminPage() {
const session = await auth();
// Check both authentication AND authorization
if (!session?.user?.email || !isAdminUser(session.user.email)) {
redirect("/"); // Silent redirect for non-admins
}
return <AdminDashboard />;
}
SaucyTech Pattern
We use isAdminUser() from lib/config/admin.ts to check admin emails. Simple and secure.
Common Pitfalls
Only protecting the UI
Hiding buttons doesn't protect data. Users can call your API directly.
Fix: Always check auth in API routes and server components. UI hiding is UX, not security.
Flash of protected content
Client-side checks show content briefly before redirecting.
Fix: Use server components with redirect() or middleware. Check before render, not after.
Forgetting dynamic route segments
/user/123 shows user 123's data to anyone.
Fix: Always verify the logged-in user owns the resource: if (params.id !== session.user.id).
Exposing admin routes
/admin should reject non-admin users, not just hide the link.
Fix: Check role/permission in every admin route. Redirect or return 403 for unauthorized users.
Need to set up authentication?
Learn how to add Google, GitHub, and other OAuth providers to your app.
Auth Setup Guide →