Authentication in Next.js: A Practical Approach
How to implement authentication in Next.js with Auth.js (NextAuth), middleware-based route protection, session management, and security best practices.
Authentication is the feature every app needs and nobody enjoys building. Next.js doesn't ship with auth built in, but Auth.js (formerly NextAuth.js) fills that gap well. Here's how to set it up properly with the App Router, protect routes with middleware, and handle sessions on both the server and client.
Setting Up Auth.js
Install the package:
npm install next-auth@beta
Create your auth configuration:
// lib/auth.js
import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
import Google from "next-auth/providers/google"
import Credentials from "next-auth/providers/credentials"
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
GitHub({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
Google({
clientId: process.env.GOOGLE_ID,
clientSecret: process.env.GOOGLE_SECRET,
}),
],
pages: {
signIn: "/login",
},
})
Set up the API route handler:
// app/api/auth/[...nextauth]/route.js
import { handlers } from "@/lib/auth"
export const { GET, POST } = handlers
Auth.js handles OAuth flows, CSRF protection, token rotation, and session management. You bring the provider credentials.
Environment Variables
Add these to your .env.local:
AUTH_SECRET=your-random-secret-here
GITHUB_ID=your-github-oauth-app-id
GITHUB_SECRET=your-github-oauth-app-secret
Generate AUTH_SECRET with:
npx auth secret
Getting the Session
Server Components
On the server, call the auth() function directly:
// app/dashboard/page.js
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
export default async function DashboardPage() {
const session = await auth()
if (!session) {
redirect("/login")
}
return <h1>Welcome, {session.user.name}</h1>
}
No hooks, no context providers, no loading states. Server Components make this clean.
Client Components
For client components, use the SessionProvider and the useSession hook:
// app/layout.js
import { SessionProvider } from "next-auth/react"
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<SessionProvider>{children}</SessionProvider>
</body>
</html>
)
}
// components/user-menu.js
"use client"
import { useSession, signIn, signOut } from "next-auth/react"
export function UserMenu() {
const { data: session, status } = useSession()
if (status === "loading") return <div>Loading...</div>
if (!session) {
return <button onClick={() => signIn()}>Sign in</button>
}
return (
<div>
<span>{session.user.email}</span>
<button onClick={() => signOut()}>Sign out</button>
</div>
)
}
Middleware-Based Route Protection
Checking auth in every page component works but breaks down as your app grows. Middleware runs before the request reaches your page, making it the right place for route-level protection:
// middleware.js
import { auth } from "@/lib/auth"
export default auth((req) => {
const isLoggedIn = !!req.auth
const isOnDashboard = req.nextUrl.pathname.startsWith("/dashboard")
const isOnLogin = req.nextUrl.pathname === "/login"
if (isOnDashboard && !isLoggedIn) {
return Response.redirect(new URL("/login", req.nextUrl))
}
if (isOnLogin && isLoggedIn) {
return Response.redirect(new URL("/dashboard", req.nextUrl))
}
})
export const config = {
matcher: ["/dashboard/:path*", "/login"],
}
The matcher config limits middleware to specific paths. Exclude static assets and API routes that don't need auth.
Database Sessions vs. JWT
Auth.js supports two session strategies:
JWT (default): Sessions are stored in an encrypted cookie. No database needed. Good for simple apps. The downside: you can't revoke sessions server-side.
Database: Sessions are stored in your database. Requires a database adapter (Prisma, Drizzle, etc.). Lets you list active sessions, revoke access, and track login history.
// lib/auth.js
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "./db"
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "database" },
// ...providers
})
For production apps handling sensitive data, use database sessions.
Role-Based Access
Extend the session with custom fields by using the callbacks option:
callbacks: {
async session({ session, user }) {
session.user.role = user.role
return session
},
}
Then check roles in your middleware or components:
const session = await auth()
if (session?.user?.role !== "admin") {
redirect("/unauthorized")
}
Best Practices
- Always use HTTPS in production. Auth cookies over HTTP are vulnerable to interception.
- Set
AUTH_SECRETto a strong random value. This encrypts your JWT tokens. - Use middleware for route protection, not individual page checks.
- Keep session data minimal. Don't store large objects in the JWT.
- Handle the loading state. Users will see a flash of unauthenticated content if you don't.
Getting auth right early saves you from painful refactors later. Auth.js with the App Router covers OAuth, credentials, sessions, and middleware — everything most apps need.