Next.js Protected Routes
Introduction
Protected routes are an essential aspect of web application security, ensuring that certain pages or resources are only accessible to authenticated or authorized users. In client-side applications, implementing protected routes typically involves checking authentication status and redirecting unauthorized users. Next.js provides several powerful ways to implement route protection due to its hybrid rendering capabilities and middleware features.
In this tutorial, we'll explore different methods to create protected routes in Next.js applications, ensuring that your sensitive content remains secure while providing a smooth user experience.
Why Do We Need Protected Routes?
Before diving into implementation, let's understand why protected routes are important:
- Security: They prevent unauthorized access to sensitive information
- User Experience: They guide users to authenticate before accessing restricted content
- Data Privacy: They ensure that personal data is only shown to its rightful owner
- Compliance: Many regulations require access controls for certain types of data
Basic Protected Route Implementation
Let's start with a simple pattern for protected routes using client-side redirects.
Method 1: Client-side Protection with useRouter
This approach uses React hooks and Next.js's useRouter
to check authentication and redirect if needed:
// components/ProtectedRoute.js
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { useAuth } from '../contexts/AuthContext'; // Your auth context
export function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && !user) {
router.push('/login');
}
}, [user, loading, router]);
if (loading) {
return <div>Loading...</div>;
}
return user ? <>{children}</> : null;
}
You can then use this component to wrap any page that needs protection:
// pages/dashboard.js
import { ProtectedRoute } from '../components/ProtectedRoute';
import DashboardContent from '../components/DashboardContent';
export default function Dashboard() {
return (
<ProtectedRoute>
<DashboardContent />
</ProtectedRoute>
);
}
Method 2: Higher-Order Component (HOC) Pattern
Another popular approach is to use a Higher-Order Component to add protection logic:
// hocs/withAuth.js
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { useAuth } from '../contexts/AuthContext';
export function withAuth(Component) {
const AuthenticatedComponent = (props) => {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && !user) {
router.replace('/login?redirect=' + router.pathname);
}
}, [user, loading, router]);
if (loading) {
return <div>Authenticating...</div>;
}
return user ? <Component {...props} /> : null;
};
return AuthenticatedComponent;
}
Usage with a page component:
// pages/profile.js
import { withAuth } from '../hocs/withAuth';
function ProfilePage() {
return (
<div>
<h1>Your Profile</h1>
{/* Profile content */}
</div>
);
}
export default withAuth(ProfilePage);
Server-Side Protection
Client-side protection is not sufficient for truly secure applications since it can be bypassed. Next.js offers server-side alternatives that provide stronger security.
Method 3: Using getServerSideProps
Next.js's data fetching method getServerSideProps
runs on the server for every request, making it perfect for authentication checks:
// pages/admin.js
import AdminDashboard from '../components/AdminDashboard';
import { getUser } from '../lib/auth'; // Your server-side auth utilities
export default function AdminPage({ user }) {
return <AdminDashboard user={user} />;
}
export async function getServerSideProps(context) {
// Get the user from the session cookie
const user = await getUser(context.req);
// If not authenticated, redirect to the login page
if (!user) {
return {
redirect: {
destination: '/login?redirect=' + context.resolvedUrl,
permanent: false,
},
};
}
// If the user doesn't have admin role
if (user.role !== 'admin') {
return {
redirect: {
destination: '/unauthorized',
permanent: false,
},
};
}
// If authenticated with correct role, return the user
return {
props: { user },
};
}
This method is powerful because:
- The protected content is never sent to the client if not authenticated
- You can check not only authentication but also authorization (roles, permissions)
- The redirect happens before the page is rendered
Next.js Middleware for Route Protection
Next.js 12+ introduced Middleware, which gives us even more power for protecting routes.
Method 4: Using Next.js Middleware
Middleware runs before a request is completed, making it perfect for authentication checks across multiple routes:
// middleware.js (root of project)
import { NextResponse } from 'next/server';
import { verifyAuth } from './lib/auth';
export async function middleware(request) {
// Get the pathname of the request
const path = request.nextUrl.pathname;
// Define public paths that don't require authentication
const publicPaths = ['/', '/login', '/register', '/about', '/api/auth'];
const isPublicPath = publicPaths.some(publicPath => path === publicPath || path.startsWith(publicPath + '/'));
if (isPublicPath) {
return NextResponse.next();
}
// Check if the user is authenticated
const token = request.cookies.get('authToken')?.value;
const isAuthenticated = token && await verifyAuth(token);
// If not authenticated and trying to access protected route, redirect to login
if (!isAuthenticated) {
return NextResponse.redirect(new URL(`/login?redirect=${path}`, request.url));
}
return NextResponse.next();
}
// Configure which paths the middleware runs on
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
This middleware will run on all routes except those specifically excluded in the matcher
configuration.
Adding Role-Based Access Control
For more advanced applications, you might need role-based protection. Let's enhance our middleware example:
// middleware.js
import { NextResponse } from 'next/server';
import { verifyAuth, getUserRole } from './lib/auth';
export async function middleware(request) {
const path = request.nextUrl.pathname;
// Define public paths
const publicPaths = ['/', '/login', '/register', '/about', '/api/auth'];
const isPublicPath = publicPaths.some(publicPath => path === publicPath || path.startsWith(publicPath + '/'));
// Define role-protected paths
const adminPaths = ['/admin', '/api/admin'];
const isAdminPath = adminPaths.some(adminPath => path === adminPath || path.startsWith(adminPath + '/'));
if (isPublicPath) {
return NextResponse.next();
}
// Check authentication
const token = request.cookies.get('authToken')?.value;
const isAuthenticated = token && await verifyAuth(token);
if (!isAuthenticated) {
return NextResponse.redirect(new URL(`/login?redirect=${path}`, request.url));
}
// Check authorization for admin paths
if (isAdminPath) {
const userRole = await getUserRole(token);
if (userRole !== 'admin') {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Real-World Example: Building a Membership Site
Let's put everything together in a real-world example of a membership site with different access levels:
// middleware.js
import { NextResponse } from 'next/server';
import { verifyAuth, getUserSubscription } from './lib/auth';
export async function middleware(request) {
const path = request.nextUrl.pathname;
// Public paths
const publicPaths = ['/', '/login', '/register', '/pricing'];
const isPublicPath = publicPaths.some(p => path === p || path.startsWith(p + '/'));
// Subscription content paths
const basicContentPaths = ['/content/basic'];
const premiumContentPaths = ['/content/premium'];
const isPremiumPath = premiumContentPaths.some(p => path === p || path.startsWith(p + '/'));
if (isPublicPath) {
return NextResponse.next();
}
// Check if user is authenticated
const token = request.cookies.get('authToken')?.value;
const isAuthenticated = token && await verifyAuth(token);
if (!isAuthenticated) {
return NextResponse.redirect(new URL('/login?redirect=' + path, request.url));
}
// For premium content, check subscription level
if (isPremiumPath) {
const subscription = await getUserSubscription(token);
if (subscription !== 'premium') {
return NextResponse.redirect(new URL('/pricing?upgrade=true', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Then, let's create our premium content page:
// pages/content/premium/advanced-tutorial.js
export default function AdvancedTutorial() {
return (
<div className="premium-content">
<h1>Advanced Next.js Techniques</h1>
<div className="premium-badge">Premium Content</div>
<section>
<h2>Dynamic Server Components with Streaming</h2>
<p>This premium tutorial covers the cutting-edge features of Next.js...</p>
{/* More premium content */}
</section>
</div>
);
}
// No need for getServerSideProps protection since middleware handles it!
Best Practices for Protected Routes in Next.js
- Defense in Depth: Implement protection at multiple layers (middleware, server components, client-side)
- Graceful Degradation: Always provide fallbacks for loading states
- Clear User Feedback: Let users know why they're being redirected
- Remember Intended Destination: Use query parameters to redirect users back after authentication
- Keep Tokens Secure: Use HTTP-only cookies for auth tokens, not localStorage
- Expiry and Refresh: Implement token expiry and refresh mechanisms
Common Pitfalls to Avoid
- Relying only on client-side checks: These can be bypassed
- Not handling loading states: Can cause flickering or multiple redirects
- Exposing sensitive routes in your sitemap or API: Ensure these are protected too
- Forgetting to redirect after login: Users should return to their intended destination
- Not properly clearing auth data on logout: Can lead to security issues
Summary
Protected routes are crucial for building secure Next.js applications. We've covered several methods to implement them:
- Client-side protection with
useRouter
and components - Higher-Order Component pattern for reusability
- Server-side protection with
getServerSideProps
- Next.js Middleware for application-wide protection
- Role-based access control for different user types
Each approach has its own advantages, and you might use different methods for different parts of your application. For maximum security, combining server-side checks with client-side enhancements provides the best user experience while maintaining strong security.
Additional Resources
- Next.js Authentication Documentation
- Next.js Middleware API
- Auth.js (formerly NextAuth.js)
- JWT Authentication Best Practices
Exercises
- Create a simple Next.js application with public and protected routes using middleware.
- Implement role-based access control with at least two different user roles.
- Add proper loading states and user feedback to your protected routes.
- Create a "remember me" feature that extends the session duration for returning users.
- Implement a password-protected sharing feature that allows non-registered users to access specific protected content with a unique link.
By implementing these patterns, you'll ensure that your Next.js applications remain secure while providing a seamless experience for your users.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)