Handling Authentication in Next.js with JSON Web Tokens.

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

  • Basic understanding of Javascript and React.

  • Node.js installed on your computer.

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.