
Implementing Authentication in Next.js Using NextAuth.js

Authentication is one of those areas where a tidy demo can make the work look much easier than it really is. It is not especially hard to get a sign‑in page rendering in Next.js, but it is very easy to end up with weak session handling, duplicated access checks, and route protection that slowly drifts out of sync with the rest of the application.
That is why NextAuth.js, now part of the wider Auth.js project, still matters. It gives us a solid baseline for providers, sessions, callbacks, and middleware‑level checks, whilst leaving enough room for us to shape the rest of the application around our own data model and authorisation rules.
This article is intentionally broader than a single Next.js routing model. The moving parts are not identical between the Pages Router and the App Router, but the underlying job is the same. We still need a reliable way to identify the user, shape the session, and enforce permissions at the right boundary.
Why Authentication in Next.js Needs Some Thought
It is easy to blur the line and assume that authentication and authorisation are basically the same thing. They are related, but they are not interchangeable. Authentication answers who we are dealing with. Authorisation answers what that signed‑in person is allowed to do after the session exists. In practice, most production bugs happen in the second part, not the first.
Next.js adds another layer to think about because rendering can happen in more than one place. We may check a session in getServerSideProps, in an API route, in a route handler, in middleware, or on the client for presentation only. If those checks are scattered across the codebase, the application becomes harder to test and easier to break when requirements change.
Why Auth.JS is Usually the Pragmatic Choice
NextAuth.js gives us provider integrations, session management, cookie handling, and callback hooks without forcing us to rebuild well‑understood security plumbing ourselves. That is useful because authentication code should be boring, reviewed, and predictable.
It also lets us keep our domain‑specific logic in a separate access layer. We can let the library manage identity and session wiring, then keep roles, tenant rules, and data permissions inside our own application code where they belong.
Where Teams Usually Overcomplicate the Design
We do not need to make every route dynamic or every component auth‑aware. The simplest pattern is usually to resolve the session as close as possible to the protected server boundary, then pass only the data the UI really needs. That keeps the client smaller and the security model easier to reason about.
If we are building for a team rather than a code kata, that separation also improves maintainability. Product changes tend to affect permissions more often than provider configuration, so those concerns should not be tangled together.
A Shared NextAuth.js Setup
A good starting point is a shared config module. We define providers and callbacks once, export the options, and reuse that configuration wherever the application needs to resolve a session. That single source of truth is much easier to test than repeating session logic in multiple files.
The example below uses GitHub as a provider for clarity, but the same structure works with credentials, email links, or enterprise single sign‑on. The important point is not the specific provider. It is the shape of the boundary.
import type { NextAuthOptions } from 'next-auth';import GitHubProvider from 'next-auth/providers/github';type AppRole = 'admin' | 'editor' | 'reader';export const authOptions: NextAuthOptions = { providers: [ GitHubProvider({ clientId: process.env.GITHUB_ID ?? '', clientSecret: process.env.GITHUB_SECRET ?? '', }), ], session: { strategy: 'jwt', }, callbacks: { jwt: async ({ token, account }) => { if (account) { token.role = 'reader' satisfies AppRole; } return token; }, session: async ({ session, token }) => ({ ...session, user: { ...session.user, role: (token.role as AppRole | undefined) ?? 'reader', }, }), },};In a real application, the role would usually come from your own database or identity layer rather than being hard‑coded in the callback. You would also normally extend the NextAuth types so the extra `role` field is properly typed throughout the codebase. Even so, this shows the broad pattern well: NextAuth.js handles the identity plumbing, whilst the session is shaped for the application we actually want to build.
Protecting a Real Server Boundary
Once that shared config exists, the next job is to enforce permissions where the sensitive work actually happens. That is usually more secure than trusting a client‑side check, because the client only controls what gets shown, not what the server allows.
A simple Pages Router API route might look like this:
import type { NextApiRequest, NextApiResponse } from 'next';import { getServerSession } from 'next-auth/next';import { authOptions } from '@/lib/auth';export default async function handler( req: NextApiRequest, res: NextApiResponse) { const session = await getServerSession(req, res, authOptions); if (!session?.user) { return res.status(401).json({ error: 'Unauthenticated' }); } if (session.user.role !== 'admin') { return res.status(403).json({ error: 'Forbidden' }); } return res.status(200).json({ ok: true, message: 'Protected server-side work would happen here.', });}That is the kind of check that actually protects data. A hidden button, a disabled form, or a client‑side redirect can improve the user experience, but none of them is a permission model on its own.
If you are using the App Router, the same rule still applies. The function signature changes and the framework surface is a bit different, but the underlying idea is identical: resolve the current session at the server boundary, then stop immediately if the user is not allowed to continue.
What Changes Between the Pages Router and the App Router
The broad design does not really change. The library config still needs to be clear. The session still needs to be shaped deliberately. The final permission check still belongs on the server.
What does change is where those checks sit. In the Pages Router, that often means `getServerSideProps` and API routes. In the App Router, it usually means route handlers, server components, or server actions. That difference matters, but it is not the main architectural decision. The main decision is whether permissions live in one obvious place or leak into every part of the application.
Common Pitfalls with NextAuth.js in Real Projects
One easy mistake is to treat callback functions as a dumping ground for business logic. They are useful, but they should stay focused on session shaping and identity mapping. If we start adding database orchestration, billing rules, and content permissions there, the auth layer becomes much harder to understand.
Another common issue is assuming middleware alone is enough. Middleware is helpful for quick redirects and optimistic checks, but sensitive operations still need secure server‑side validation against the current session and the underlying data. Middleware is a useful first gate, not the final authority.
We also need to keep provider configuration and secrets management disciplined. It is tempting to scatter environment reads around the codebase, but centralising that setup makes failures more visible and reduces the chance of a broken deployment due to one missing variable.
How to Keep the Auth Layer Sane
The auth layer is much easier to live with when it stays small. We can unit test callback behaviour, role mapping, and access helpers separately from the UI, then integration test the protected routes that really matter. That keeps the risky parts visible without forcing every auth change through a giant end‑to‑end suite.
Longer term, it helps to model authorisation explicitly. A small access layer such as `canEditArticle(session, article)` or assertAdmin(session) reads much better than inlined role checks scattered through route handlers and components. As the product grows, that gives us one place to evolve tenant rules, content scopes, or feature flags without rewriting the whole authentication story.
It also helps to be honest about what the auth layer should not do. It should not become a vague home for unrelated product rules. It should not carry every branching decision in the system. It should not make ordinary data access harder to follow. Good authentication code is usually quite boring. That is a strength, not a weakness.
Where This Leaves Us
Authentication in Next.js becomes much more manageable when session handling, route protection, and authorisation are treated as one coherent architectural concern instead of a few scattered implementation details. NextAuth.js is useful precisely because it lets us keep the plumbing predictable whilst still shaping the application around our own business rules.
That is the real win here. Not that we can add a sign‑in button quickly, but that we can keep the authentication story understandable six months later when the product has more routes, more roles, and more awkward edge cases than the original demo ever had.
If you want the precise framework semantics behind this approach, these official references are still the right place to check:
- Auth.js
- Next.js Authentication Guide for the App Router
- Next.js Authentication Guide for the Pages Router
Wrapping up
Authentication in Next.js becomes much more manageable when session handling, route protection, and authorisation are treated as one coherent architectural concern instead of a few scattered implementation details. NextAuth.js is useful precisely because it gives us the boring, dependable plumbing, while still leaving room for our own application rules where they belong.
That is the real value here. Not that we can get a sign‑in screen working quickly, but that we can keep the authentication story understandable once the application grows, the number of roles increases, and the awkward edge cases start to appear.
Key Takeaways
- Authentication and authorisation should be treated as separate concerns, even when they share the same session object.
- NextAuth.js is most useful when it handles identity and session plumbing, while domain permissions stay in our own access layer.
- Route protection is strongest when optimistic UI checks are backed by secure server‑side validation.
- The Pages Router and the App Router expose different APIs, but the underlying design principles stay broadly the same.
- Good authentication code is usually quite boring, and that is exactly what we want.
Related Articles

Understanding Transient Props in styled‑components. Understanding Transient Props in

Using Middleware in Next.js for Route Protection. Using Middleware in Next.js for Route Protection

Stopping Propagation vs. Preventing Default in JavaScript. Stopping Propagation vs. Preventing Default in JavaScript

Appending and Prepending Items to an Array. Appending and Prepending Items to an Array

Rethinking Carousels: Going Around in Circles. Rethinking Carousels: Going Around in Circles

Array.from() and Array.of() in JavaScript. Array.from()andArray.of()in JavaScript
Fast and Slow Pointers: Solving the 'Linked List Cycle' Problem. Fast and Slow Pointers: Solving the 'Linked List Cycle' Problem

Exploring the call() Method in JavaScript. Exploring the
call()Method in JavaScript
JavaScript Error Handling Patterns. JavaScript Error Handling Patterns

JavaScript's typeof Operator: Uses and Limitations. JavaScript's
typeofOperator: Uses and Limitations
The arguments Object vs. Rest Parameters in JavaScript. The
argumentsObject vs. Rest Parameters in JavaScript
Understanding Media Queries in CSS. Understanding Media Queries in CSS