JSON Web tokens or JWT tokens are great for handling authentication in decoupled applications (The front-end and back-end are independent of each other) due to their stateless nature.
When I got started with Next.js, I was struggling with how to implement authentication using JWT tokens in Next.js, If you are facing similar challenges or you want to try out a new approach to authentication in Next.js, then this blog post is for you.
We will be building a web application with a simple login, dashboard and profile page and then add authentication to some of the pages, by the end of this post you will be able to add authentication to your Next.js app.
Prerequisites
Installing Next.js
We will get started with installing Next.js on our computer using the create-next-app
command shown below, you will be prompted to choose some configurations for this application. I chose to use the /src
directory and configured the import alias to @/*
for this tutorial, you can choose any configuration you are familiar with and kick off with the installation.
npx create-next-app nextjs-auth
Setting up a dummy backend with Next.js API routes.
We will set up a dummy backend that will authenticate users and generate JWT tokens for the user. We will first start by installing the jsonwebtoken
library to help with generating and validating JWT tokens.
cd nextjs-auth
npm install jsonwebtoken
After the installation is complete, create a new directory named utils
inside the src
directory and create an index.js
file inside this newly created directory. Copy the code below into the content of the index.js
file. In a real app, we will probably be using a database to store the users and the jwtSecret
will be stored in the .env.local
file.
// src/utils/index.js
export const users = [
{ id: 1, name: 'John Doe', email: "johndoe@gmail.com", password: "johnpassword" },
{ id: 2, name: 'Mary Doe', email: "marydoe@gmail.com", password: "marypassword" },
];
export const jwtSecret = 'mySecret';
In the src/pages/api
directory, create two files login.js
and me.js
which maps to the routes /api/login
and /api/me
respectively. Add the code below to the login.js
file.
// src/pages/api/login.js
import {users, jwtSecret} from '@/utils';
import jwt from 'jsonwebtoken';
export default async function handler(req, res) {
const {email, password} = req.body;
switch(req.method) {
case 'POST':
if(!email) {
return res.status(400).json({message: 'Please provide an email address'});
}
if(!password) {
return res.status(400).json({message: 'Please provide a password'});
}
const user = users.find(user => user.email === email && user.password === password);
if(!user) {
return res.status(401).json({message: 'Invalid Credentials'});
}
const token = jwt.sign({id: user.id}, jwtSecret);
res.json({user, token});
break;
default:
res.status(405).json({message: 'Method not supported'});
}
}
We validate the email and password provided by the user and generate a JWT token if the provided email and password exist in the dummy users. Also, add the code below to the me.js
file.
// src/pages/api/me.js
import jwt from 'jsonwebtoken';
import { users, jwtSecret } from "@/utils";
export default async function handler(req, res) {
const auth = req.headers['authorization'];
switch(req.method) {
case 'GET':
if(!auth) {
return res.status(401).send({message: 'Invalid bearer token'});
}
const token = auth.split(' ').slice(-1)[0];
try {
const data = jwt.verify(token, jwtSecret);
const user = users.find(user => user.id === data.id);
if(!user) {
throw new Error();
}
res.json(user);
} catch (error) {
res.status(401).json({message: 'Malformed bearer token'})
}
break;
default:
res.status(405).json({message: 'Method not allowed'});
}
}
We try to get the JWT token in the authorization header of the incoming request, verify and decode it, and find the user from dummy users using the decoded payload. We send the found user or an error message to the client.
Handling Authentication on the Client Side
Getting Started
We will get started by installing Tailwind CSS and its dependencies to help us with utility classes for styling by running the command below:
npm install -D tailwindcss autoprefixer postcss
npx tailwindcss init -p
Replace the newly generated tailwind.config.js
file with the content below.
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
}
And also replace the src/styles/global.css
file with this:
@tailwind base;
@tailwind components;
@tailwind utilities;
Start the development server by running npm run dev
, open the browser and visit localhost:3000
.
Creating Reusable React Components
We will create two basic React components, an Input and a Button component. Create the src/components
directory, inside this directory, create an Input.js
and a Button.js
file.
Copy the code below into the Input.js
file.
// src/components/Input.js
const Input = ({ label, ...props }) => {
return (
<label className="w-full block space-y-1 group">
<span className="block font-medium text-sm text-gray-700 group-focus-within:text-gray-900 focus:ring-0 focus:outline-none">
{label}
</span>
<input
className="p-2 w-full border text-sm border-gray-600 block placeholder:text-gray-800 placeholder:text-sm rounded-md focus:border-gray-800 "
{...props}
/>
</label>
);
};
export default Input;
Copy the code below into the Button.js
file
// src/components/Button.js
const Button = ({ children, ...props }) => {
return (
<button
className="bg-slate-700 w-full shadow-sm block rounded-sm px-2 py-1.5 ring-0 text-white focus:outline-none hover:bg-slate-900"
{...props}
>
{children}
</button>
);
};
export default Button;
Building the Login Page
Inside src/pages
directory, create a new file login.js
and insert the code below.
// src/pages/login.js
import React, { useState } from "react";
import Button from "@/components/Button";
import Input from "@/components/Input";
const LoginPage = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = async (event) => {
event.preventDefault();
if (!email || !password) {
return;
}
};
return (
<div className="w-screen h-screen flex items-center justify-center">
<div className="w-[350px] p-4 bg-slate-100">
<form onSubmit={handleSubmit} className="w-full space-y-2">
<Input
label="Email"
value={email}
type="email"
onChange={(evt) => setEmail(evt.target.value)}
/>
<Input
type="password"
label="Password"
value={password}
onChange={(evt) => setPassword(evt.target.value)}
/>
<Button type="submit">Log In</Button>
</form>
</div>
</div>
);
};
export default LoginPage;
We created an email and password state for storing the value of the Email and Password Input components respectively. we also added a handleSubmit
function which is executed when the form is submitted. Later, we will add logic for submitting the form to the server.
Building the Dashboard Page
Inside the src/pages
directory, create a new file dashboard.js
, and insert the code below.
// src/pages/dashboard.js
const DashboardPage = () => {
return <div className="px-4 py-5">
Hello User
</div>
}
export default DashboardPage;
The word User will later be replaced with the name of the currently authenticated user.
Building the Profile Page
Inside the same directory, create a new profile.js
file and insert the code snippet below.
// src/pages/profile.js
const ProfilePage = () => {
return (
<div className="py-4 pt-10 pb-4">
<div className="max-w-[50rem] w-[90%] mx-auto rounded-sm shadow-sm">
<div className="bg-gray-200 text-slate-800 p-3">
Profile Information
</div>
<div className="flex flex-wrap items-center">
<div className='w-full md:w-1/3 p-2 md:p-3 text-gray-600'>Email address</div>
<div className="w-full md:w-2/3 p-2 md:p-3 text-gray-800">user@gmail.com</div>
</div>
</div>
</div>
);
};
export default ProfilePage;
We will later replace the dummy email address with that of the logged-in user.
Creating a Wrapper around Fetch API
By default the Fetch API does not throw an error when an HTTP error status code is received from the server, we want to create a reusable wrapper function around Fetch API to add the above functionality and also handle deserialization.
Inside the utils
directory, create a file request.js
and insert the code below.
// src/utils/request.js
const request = async (url, { method = "GET", headers = {}, body }) => {
try {
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await response.json();
if (!response.ok) {
const error = new Error(data.message || 'Server returned an error response');
error.data = data;
throw error;
}
return data;
} catch (error) {
error = error.data ? error : new Error('Fetching data failed');
throw error
}
};
export default request;
Creating an Auth Store
Authentication data like the JWT token or the user profile will need to be accessed by many React components and as such, it will be better to store them globally in a store. In this post, we will be using React Context but you can implement the same functionality with any other store.
Create a src/store
directory and inside it, create an auth.js
file and insert the code below.
// src/store/auth.js
import { createContext, useReducer, useCallback } from "react";
import request from "@/utils/request";
import {useRouter} from 'next/router';
const AuthContext = createContext(null);
const initialState = {
data: null,
error: null
}
const reducer = (state, action) => {
switch(action.type) {
case 'LOGIN_SUCCESS':
return {
data: action.payload,
error: null
}
case 'LOGIN_ERROR':
return {
data: null,
error: action.payload
}
case 'LOGOUT':
return initialState;
default:
return state;
}
}
export const AuthContextProvider = ({children}) => {
const [authState, dispatch] = useReducer(reducer, initialState);
const {push} = useRouter();
const login = useCallback((responseData) => {
// Store jwt in local storage
localStorage.setItem('token', responseData.token);
dispatch({
type: 'LOGIN_SUCCESS',
payload: responseData
});
}, [dispatch]);
// Executes when the page is reloaded or when the authenticated route is visited
const init = useCallback(async () => {
// Get JWT token from local storage
const token = localStorage.getItem('token');
// Dispatch an error state if no token
if(!token) {
dispatch({
type: 'LOGIN_ERROR',
payload: 'User not authenticated'
})
return;
}
try {
// Check validity of token and get user data from server
const user = await request('/api/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
// Update the Auth state with user data and jwt
dispatch({
type: 'LOGIN_SUCCESS',
payload: {
token,
user
}
})
} catch (error) {
// Update the Auth state with an error
dispatch({
type: 'LOGIN_ERROR',
payload: error.message
})
}
}, [dispatch]);
const logout = useCallback(async () => {
// Change to the current page to login page
await push('/login');
// When the page is changed, remove the token from localstorage
localStorage.removeItem('token');
dispatch({
type: 'LOGOUT'
})
}, [push, dispatch]);
return (<AuthContext.Provider value={{...authState, init, login, logout}}>
{children}
</AuthContext.Provider>)
}
export default AuthContext;
We are creating a new React Context with an initial value set to null. We also created an AuthContextProvider
component which is a wrapper around AuthContext.Provider
containing an Auth state managed by useReducer
hook and functions like login
, logout
& init
that dispatch actions to update the state.
We wrapped these functions with useCallback
hook to prevent them from recreating when the Auth state changes and the AuthContextProvider
component rerenders. Finally, we pass the Auth state and dispatcher functions to the AuthContext.Provider
value prop, updating the value of the AuthContext
.
We will create a simple custom hook to be able to access the AuthContext
, create a new directory src/hooks
, inside this directory, create a file useAuth.js
and add the code below.
// src/hooks/useAuth.js
import { useContext } from "react";
import AuthContext from "@/store/auth";
const useAuth = () => {
const ctx = useContext(AuthContext);
if(!ctx) {
throw new Error('AuthProvider is not yet provided');
}
return ctx;
}
export default useAuth;
We will now wrap the root component at src/pages/_app.js
with AuthContextProvider
component so that the context will be accessible to all child components. Replace the code in the src/pages/_app.js
file with the code below
import "@/styles/globals.css";
import { AuthContextProvider } from "@/store/auth";
export default function App({ Component, pageProps }) {
return (
<AuthContextProvider>
<Component {...pageProps} />
</AuthContextProvider>
);
}
We have now successfully created an AuthContext
store with login
, logout
and init
functions to update the store and a custom hook to access the store, we will make use of values in the AuthContext
to implement authentication.
Finishing the Login Page
We previously created a simple login page with a form containing email and password inputs, we are going to add functionality for submitting the form data to our dummy backend and logging in the user. Open src/pages/login.js
and replace the existing code with the code below.
import React, { useState } from "react";
import { useRouter } from "next/router";
import Button from "@/components/Button";
import Input from "@/components/Input";
import request from "@/utils/request";
import useAuth from "@/hooks/useAuth";
const LoginPage = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const router = useRouter();
const { login } = useAuth();
const handleSubmit = async (event) => {
event.preventDefault();
// Validate data
if (!email || !password) {
return;
}
try {
// Send email and password data to our backend
const data = await request("/api/login", {
method: "POST",
body: {
email,
password,
},
});
// Update the Auth state and store the token in local storage
login(data);
router.push("/dashboard");
} catch (error) {
console.log(error);
}
};
return (
<div className="w-screen h-screen flex items-center justify-center">
<div className="w-[350px] p-4 bg-slate-100">
<form onSubmit={handleSubmit} className="w-full space-y-2">
<Input
label="Email"
value={email}
onChange={(evt) => setEmail(evt.target.value)}
/>
<Input
label="Password"
value={password}
onChange={(evt) => setPassword(evt.target.value)}
/>
<Button type="submit">Log In</Button>
</form>
</div>
</div>
);
};
export default LoginPage;
The handleSubmit
function was updated, and we are sending a post request to /api/login
route with the users' email and password. If the request succeeds, we log in the user by calling the login
function from the AuthContext
store and then redirect the user to the dashboard page.
Adding Authentication to the Dashboard Page
We previously created a useAuth
custom hook, to protect a page from unauthenticated users we can check whether the data property returned by the useAuth hook is not null. Although the approach above is good, we will make use of a better approach that utilizes Next.js file-based routing.
Update src/pages/dashboard.js
file with the following code.
// src/pages/dashboard.js
import useAuth from "@/hooks/useAuth";
const DashboardPage = () => {
const {data} = useAuth()
return <div className="px-4 py-5">
Hello {data.user.name}
</div>
}
// Add authentication to this page
DashboardPage.isAuth = true;
export default DashboardPage;
We are displaying the name of the logged-in user which is gotten from the AuthContext
, we also added a property isAuth
to DashboardPage
with the value true, we will do the same for any page that needs to be protected.
Properties can be assigned to functions since functions are objects in Javascript, we will later retrieve this property inside the root App component in src/pages/_app.js
.
Also, update src/pages/_app.js
file with the following code:
// src/pages/_app.js
import "@/styles/globals.css";
import Auth from "@/components/Auth";
import { AuthContextProvider } from "@/store/auth";
export default function App({ Component, pageProps }) {
// Check whether the page needs to be protected
const isAuth = Component.isAuth;
// Wrap the page with an Auth component if it needs to be protected
return (
<AuthContextProvider>
{isAuth && (
<Auth>
<Component {...pageProps} />
</Auth>
)}
{!isAuth && <Component {...pageProps} />}
</AuthContextProvider>
);
}
You may need to understand Next.js file-based routing to fully understand the code above, Next.js rerenders this App component whenever a new route is visited. Suppose we visit /dashboard
, Next.js will rerender this root App component with the default export from /pages/dashboard.js
as Component
prop and the return value of data fetching functions like getServerSideProps
from /pages/dashboard.js
as pageProps
.
We are checking if the isAuth
property exists on Component
. If it does, it means we want the page to be protected from unauthenticated users, we do this by wrapping Component
with an Auth
component that we are yet to implement.
Inside src/components
directory, create a new file Auth.js
and add the code below.
// src/components/Auth.js
import { useRouter } from "next/router";
import useAuth from "@/hooks/useAuth";
const Auth = ({ children }) => {
const { init, data, error } = useAuth();
const { push } = useRouter();
useEffect(() => {
// Get user details if previously logged in or return an error state
if (!data) {
init();
}
}, [data, init]);
useEffect(() => {
if (error) {
push("/login");
}
}, [error, push]);
if (data) {
return children;
}
// Show loading state if the user is not authenticated
return (
<div className="w-screen h-screen flex items-center justify-center">
<div>Loading ...</div>
</div>
);
};
export default Auth;
The Auth.js
wraps any protected page, it does the following:
Redirects unauthenticated users to the login page.
Updates the Auth state when the browser is reloaded or when an authenticated visits a protected page.
Shows a loading text when the authentication state of the user is unknown.
if you visit /dashboard
without logging in previously, you will be redirected back to the login
page.
Protecting the Profile Page
With the previously implemented authentication logic, adding authentication to any page is now super easy. To add authentication to the ProfilePage
component, we just add isAuth
property to ProfilePage
with the value true. Open the src/pages/profile.js
and update the previous code to the code below.
import useAuth from '@/hooks/useAuth.js';
const ProfilePage = () => {
const {data} = useAuth()
return (
<div className="py-4 pt-10 pb-4">
<div className="max-w-[50rem] w-[90%] mx-auto rounded-sm shadow-sm">
<div className="bg-gray-200 text-slate-800 p-3">
Profile Information
</div>
<div className="flex flex-wrap items-center">
<div className="w-full md:w-1/3 p-2 md:p-3 text-gray-600">Email address</div>
<div className="w-full md:w-2/3 p-2 md:p-3 text-gray-800">{data.user.email}</div>
</div>
</div>
</div>
);
};
// Add authentication to this page
ProfilePage.isAuth = true;
export default ProfilePage;
We are also displaying the email address of the currently logged-in user.
Adding a Default Layout with Logout Button to Protected Pages
We will create a simple layout for the protected pages, the layout will consist of a header with links to different pages and a logout button. Inside src/pages
directory, create a new file AuthLayout.js
and add the code below.
import React from "react";
import Link from "next/link";
import useAuth from "@/hooks/useAuth";
const AuthLayout = ({ children }) => {
const { logout } = useAuth();
return (
<>
<header className="px-5 py-3 h-16 bg-cyan-700 flex justify-between items-center shadow-sm">
<h2 className="text-white text-lg font-semibold tracking-wider">
<Link href="/dashboard">Authenticator</Link>
</h2>
<div className="flex items-center space-x-4 text-white font-medium">
<Link href="/profile" className="hover:text-sky-200">
Profile
</Link>
<button onClick={logout} className="hover:text-sky-200">
Logout
</button>
</div>
</header>
<main>{children}</main>
</>
);
};
export default AuthLayout;
The Logout
button when clicked executes the logout
function in the AuthContext
. To use this layout, open src/pages/_app.js
and replace the existing code with the code below.
import "@/styles/globals.css";
import Auth from "@/components/Auth";
import { AuthContextProvider } from "@/store/auth";
import AuthLayout from "@/components/AuthLayout";
export default function App({ Component, pageProps }) {
const isAuth = Component.isAuth;
return (
<AuthContextProvider>
{isAuth && (
<Auth>
<AuthLayout>
<Component {...pageProps} />
</AuthLayout>
</Auth>
)}
{!isAuth && <Component {...pageProps} />}
</AuthContextProvider>
);
}
The application is now fully completed, you can test the application by logging in with any user details from users
array. A demo of the application can be found below.
You can download the source code from github if the code above is not working as expected. We have learnt how to add authentication to our Next.js application with JSON web tokens leveraging Next.js file-based routing feature.