Recently I started looking into using NextJS and NextAuth as I've heard lots of good things about how it simplifies the development process and also, I am always trying to keep up with the latest technologies and stay up to date as much as possible. However, implementing the NextAuth authentication provider with my own MySQL database and ensure the user was logged in before showing sensitive information proved to be tricker than I expected, so hopefully this blog post, will save you the two or three 3 days I spent trying to figure what the issue was.

In case you don't know what it is, NextJS is effectively a layer or framework on top of ReactJS that supports a hybrid of static and server side rendering, typescript support, smart bundling and route pre-fetching among other benefits. NextJS allows you to create a frontend/backend project in one and each page that you want to render, is just added to a pages folder which automatically creates you a route so there is no need to configure a Router manually within app.js like you would in a standard create-react-app type project. For API endpoints, you can create a typescript file in the /api subdirectory and that automatically creates a request URL, and don't worry just because the API is part of same project, NextJS uses intelligent bundling so that the client side code is no bigger than what is necessary and same goes for the backend.

NextAuth on the other hand provides a simple already implemented authentication system with sign up, sign in, and sign out for multiple different providers such as Apple, GitHub, Twitter including using your own authentication database if requried - which is where this blog post comes in, and in this tutorial this is focussed on the sign in (if I have issues with the forgotten password side of things I'll potentially do another post).

When I started looking into this, I already had a basic user database that I wanted to hook in to, using my own sign in form. NextAuth does provide an auto generated sign in page if you want to use that, but I wanted to use my own form to ensure it matched with my own formatting and style of the site, so this is what this blog post will show as there some lacking on documentation or examples on how to implement this so thought I'd write this post so hopefully you don't need to spend 3 days trying to figure it out like I did 😊

Note: This tutorial is based on using a Windows PC and MySQL server but the same setup should be pretty much the same. Also note that for the database access I am using Prisma for MySQL Database access from the projects backend. I will briefly explain what I am doing with the prisma requests, but this isn't focussed towards a prisma tutorial.

Starting your Project

Locate where you want to store your project and open a command prompt or terminal window to your directory and run the following command:

npx create-next-app my-project --use-npm --example "https://github.com/vercel/next-learn-starter/tree/master/learn-starter"

This will create a new project under the directory my-project. If you go into the my-project directory, you should see a similar file layout as below:

File structure after running create-next-app command

Next change directory into my-projects run from your terminal npm run dev and it will start the next development server and you can then browse to http://localhost:3000 and you should see the following: (note all further commands are run from the my-project directory)

NextJS Welcome Screen on new Project

If you see the below error screen instead of the Welcome to NextJS screen above then make sure that you are running the project from the actual path on your PC, if it is from a symlink then it may not work.

NextJS Initial Error if Symlink is Used

In my-project/pages there is an index.js file. We're going to use Typescript so rename this to be index.tsx.

We then need to install typescript so this can be done with the following command:

npm install --save-dev typescript @types/react

Then restart your dev server, you should see something outputted saying that typescript was detected and that tsconfig.json file has been created for you.

Next lets set up the user table we're going to use. Remember, I'm using prisma for the database management but you don't need to, use whatever you normally use to query the database.

Setting up the database

First we need to create a .env file that will store a configuration such as our database query string. This should be created in the root of your my-project folder.

Add the following to your newly created .env file

DATABASE_URL="mysql://username:password%@127.0.0.1:3306/tutorial"

Change your the username and password attributes to a valid MySQL login, 127.0.0.1 might need to be changed depending on where your database server is located and on the end is tutorial this is our database name that we're going to use.

To install prisma run the following:

npm install prisma --save-dev
npx prisma

Next create a folder in the root of your my-project folder called prisma and add a new file called schema.prisma then add the following contents into this file:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model users {
  userId        Int         @id @default(autoincrement())
  registeredAt  DateTime   @default(now())
  firstName     String      @db.VarChar(250)
  lastName      String      @db.VarChar(250)
  email         String      @db.VarChar(250)
  password      String      @db.VarChar(250)
  isActive      String      @default("1") @db.Char(1)
}

This is setting up prisma to what JS file will be used (auto generated by prisma) and the datasource so which database engine and which environment variable to extract from the .env file to determine the database connection string. After that is the model users which then determines how our users table is created.

If you are manually creating the database and not using prisma you can run the following SQL statement:

CREATE TABLE `users` (
  `userId` int(11) NOT NULL AUTO_INCREMENT,
  `firstName` varchar(250) COLLATE utf8mb4_unicode_ci NOT NULL,
  `lastName` varchar(250) COLLATE utf8mb4_unicode_ci NOT NULL,
  `email` varchar(250) COLLATE utf8mb4_unicode_ci NOT NULL,
  `password` varchar(250) COLLATE utf8mb4_unicode_ci NOT NULL,
  `isActive` char(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '1',
  `registeredAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `expires` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`userId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Run npx prisma migrate dev to allow prisma to create your database table. It will ask you for a name of the migration so you can just call it something like init.

This is where it might be handy to have access to the database table to add a user manually for the time being. run npx prisma studio and once started open a new browser window to http://localhost:5555 and you should see something like below:

Initial load screen of prisma studio

From All Models select users and you should see the following:

Blank table of prisma studio

Click add record at the top and add a user.

You only need to enter something in the following fields, the other will be auto populated based on the defaults provided in the model:

  • firstName
  • lastName
  • email
  • password

Once you've entered a record, make sure you press the green "Save 1 changes" button.

I've created a record with the following details:

Creating your login form

Next up we want to create a login form that allows the user to enter their email and password to login.

Inside /my-project/pages/index.tsx change the home function to look like the below:

export default function Home() {

  const [user, setUser] = useState('');
  const [password, setPassword] = useState('');
  const [loginError, setLoginError] = useState('');
  const handleLogin = (event) => {
      event.preventDefault();
      
      
  }
  
  return (
    <form onSubmit={handleLogin}>
      {loginError}
      <label>
        Email: <input type='text' value={user} onChange={(e) => setUser(e.target.value)} />
      </label>
      <label>
        Password: <input type='password' value={password} onChange={(e) => setPassword(e.target.value)} />
      </label>
        <button type='submit'>Submit login</button>
    </form>
  )
}

This creates a very basic (not very pretty form) with an email and password field and a submit button that when pressed calls the handleLogin method and just prevents the form submit doing the HTML default action for a form.

Next we want to prepare our API.

Setting up the API

First thing we need to do is to install next-auth. This can be done using the following command:

npm install next-auth

We need to set up a NEXTAUTH url within our .env file. Locate your .env file inside your my-project folder and add the following:

NEXTAUTH_URL=http://localhost:3000

Now we need to set up our next-auth implementation. Under /my-project/pages create a new directory called api and create a new directory inside api called auth and in there create a file called [...nextauth].ts (full path will be /my-project/pages/api/auth/[...nextauth].ts.

This is creating an API endpoint so that whenever a request goes to http://localhost/api/auth/... (... being the authentication method we need) it will always be handled by that [...nextauth].ts script.

Inside this script we need to add the following, each section will be explained afterwards:

import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'
import { PrismaClient } from '@prisma/client'
let userAccount = null;

const prisma = new PrismaClient();

const configuration = {
    cookie: {
        secure: process.env.NODE_ENV && process.env.NODE_ENV === 'production',
    },
    session: {
        jwt: true,
        maxAge: 30 * 24 * 60 * 60
    },
    providers: [
        Providers.Credentials({
            id: 'credentials',
            name: "Login",
            async authorize(credentials) {
                const user = await prisma.users.findFirst({
                    where: {
                        email: credentials.email,
                        password: credentials.password
                    }
                });

                if (user !== null)
                {
                    userAccount = user;
                    return user;
                }
                else {
                    return null;
                }
            }
        })
    ],
    callbacks: {
        async signIn(user, account, profile) {
            if (typeof user.userId !== typeof undefined)
            {
                if (user.isActive === '1')
                {
                    return user;
                }
                else
                {
                    return false;
                }
            }
            else
            {
                return false;
            }
        },
        async session(session, token) {
            if (userAccount !== null)
            {
                session.user = userAccount;
            }
            else if (typeof token.user !== typeof undefined && (typeof session.user === typeof undefined 
                || (typeof session.user !== typeof undefined && typeof session.user.userId === typeof undefined)))
            {
                session.user = token.user;
            }
            else if (typeof token !== typeof undefined)
            {
                session.token = token;
            }
            return session;
        },
        async jwt(token, user, account, profile, isNewUser) {
            if (typeof user !== typeof undefined)
            {
                token.user = user;
            }
            return token;
        }
    }
}
export default (req, res) => NextAuth(req, res, configuration)

Right, now lets breakdown what's going on here.

cookie: {
    secure: process.env.NODE_ENV && process.env.NODE_ENV === 'production',
},
redirect: false,
session: {
    jwt: true,
    maxAge: 30 * 24 * 60 * 60
    },

The section above sets up some basic settings. The cookie/secure option is so that the secure flag is only set on the cookie when you are in production mode when presumably you would be using HTTPS, but turns this flag off for local development when you are likely just using HTTP.

In the session, we enable JWT which stands for JSON Web Token, this will provide our user model in the session so we can access through the lifetime of the user being logged in and the max age sets the maximum age of when the session expires if its not used (it will automatically be incremented to each time the user makes a request).

The next section is the providers section:

providers: [
        Providers.Credentials({
            id: 'credentials',
            name: "Login",
            async authorize(credentials) {
                const user = await prisma.users.findFirst({
                    where: {
                        email: credentials.email,
                        password: credentials.password
                    }
                });

                if (user !== null)
                {
                    userAccount = user;
                    return user;
                }
                else {
                    return null;
                }
            }
        })
    ],

This provides an array of providers, in our case we only have one which is the credentials provider, but you can add other providers such as GitHub, Google or Apple for example.

The id is used so that when we call the signin method from the frontend we trigger the provider that we need, in this case the credentials.

Next we have an async authorize method. This method takes a parameter called credentials. Credentials will hold the user and password that you will send from the frontend with the sign in information. I am then looking up in the database using the prisma client for a record in the database with the email address and password combination. If you are writing the query yourself, the above await prisma.users.findFirst({...}) is the equivalent of running the SQL:

SELECT * FROM users WHERE email='value' AND password='value';

If the database returns a value, then that user is returned otherwise null. In our case because we're using prisma an object containing each field and value will be returned.

WARNING: As the user being returned will contain each field from the database that includes the password so you will likely want to create a new object based on only the information you actually need in the session, for example, the name and user id and isActive flag, this is only for demo purposes and is not recommended for a real project.

Next up we have a series of callbacks. Note that the callbacks will take various parameters but not all of them are required for this particular provider that we're using and depending on the provider, some information may differ - the below is only relevant for the credentials provider.

async signIn(user, account, profile) {
            if (typeof user.userId !== typeof undefined)
            {
                if (user.isActive === '1')
                {
                    return user;
                }
                else
                {
                    return false;
                }
            }
            else
            {
                return false;
            }
        },

The first callback is the signIn method. This is confirming that the user is actually allowed to sign in. If we searched in the database for isActive=1 only, we wouldn't be able to tell the user, whether their password was wrong, or if there account was actually disabled, so here in this callback we use the user model that was returned by the signIn method and check if isActive is equal to 1 meaning that the user can signin in otherwise we return false.

async jwt(token, user, account, profile, isNewUser) {
            if (typeof user !== typeof undefined)
            {
                token.user = user;
            }
            return token;
        }

Don't worry, I've not skipped the session, callback, but the jwt callback is used first and this sets up the data required for the session.

The user parameter contains the user model returned by the signIn method, but note only on the first call to this callback, i.e. when the user first signs in, so here, we're checking if the user is not undefined, and if so, add the user key to the token and set it to be the value of the user model, otherwise it just returns the token.

async session(session, token) {
            if (userAccount !== null)
            {
                session.user = userAccount;
            }
            else if (typeof token.user !== typeof undefined && (typeof session.user === typeof undefined 
                || (typeof session.user !== typeof undefined && typeof session.user.userId === typeof undefined)))
            {
                session.user = token.user;
            }
            else if (typeof token !== typeof undefined)
            {
                session.token = token;
            }
            return session;
        }

If the userAccount is not null (that is created at the top of the script and then set on the authorize request) and then sets this in the session with the user key.

Or if the user model is inside the token, then the user from the token is re-added back to the session.user otherwise if the token is set then the session.token is set to the value of the token parameter and then the session is returned. The reason for the first else if was it seemed like getting the session from the frontend had a slightly different object layout compared to getting the session from the API request.

That's the API built but we now need to tie the frontend form with the API.

Integrating frontend with nextauth API

Go back to /my-project/pages/index.tsx and add the following import line:

import {signIn} from 'next-auth/client'
import {useRouter} from "next/router";

After the state variables, create an instance of the router as follows:

const router = useRouter();

This will allow us to redirect to another page (not that we have one) if signed in successfully.

Change your handleSubmit method so that it now looks like the below:

const handleSubmit = (event) => {
        event.preventDefault();
        event.stopPropagation();

        signIn("credentials", {
            email, password, callbackUrl: `${window.location.origin}/dashboard`, redirect: false }
        ).then(function(result){
            if (result.error !== null)
            {
                if (result.status === 401)
                {
                    setLoginError("Your username/password combination was incorrect. Please try again");
                }
                else
                {
                    setLoginError(result.error);
                }
            }
            else
            {
                router.push(result.url);
            }
        });
    }

This calls the signin method using the credentials provider and passes the email and password that we need to authenticate against, and the callbackUrl is used when we deem the signin successful and is passed back to us in the result object that is returned in the promise.

If signing is not successful, then result.error will be null so we check if an error exists and then check the HTTP status code, therefore if 401 unauthorised we show a username/password combination error by setting loginError, otherwise we show the error result that was returned by nextauth and set loginError to be the error returned.

If everything is OK, we use the router.push and provide the URL in the result object.

Before we can test, we need to create a new file in the /my-project/pages directory called _app.js.

Once the file is created add the following:


import {Provider} from 'next-auth/client'

export default function Blog({Component, pageProps}) {


    return (
        <Provider options={{clientMaxAge: 0}} session={pageProps.session}>
            <Component {...pageProps} />
        </Provider>
    )
}

That's it, we've created a very basic form using an api endpoint using nextjs and created an authentication provider for using our own database using nextauth. If you enter your user credentials correctly, it should redirect to /dashboard (you'll get a 404 page as we've not actually created that page) or if you put the wrong password in, you'll get the error message appear at the top of the page.

Lets finish up on a quick on checking the session.

From the frontend, e.g. from the pages files or from your components, you can retrieve the session using the following:

const [session, loading] = useSession()

You can check the loading variable to either show a loading spinner or blank page and then what I've done is have a method called isSessionValid and pass it the session object to check the session is valid before showing the privileged content or showing an error.

For example, the component might be something like

const [session, loading] = useSession()

    if (!loading)
    {
        if (isSessionValid(session))
        {
            return (
                <div className='wrapper'>
                    Welcome {session.user.firstName}
                </div>
            )
        }
        else
        {
            return (
                <div className='wrapper'>
                    <TopNav />
                    <p>You are not logged in</p>
                </div>
            )
        }
    }
    else
    {
        return null;
    }

The isSessionValid function looks like the below:

export const isSessionValid = (session) => {
    if (typeof session !== typeof undefined && session !== null && typeof session.user !== typeof undefined)
    {
        return true;
    }
    else
    {
        return false;
    }
}

This has been further improved by creating a Wrapper component which does the session checking and then only shows the children if the session is valid, otherwise shows the login error.

For example, my dashboard component might look like:

<Wrapper>
	<BreadcrumbNav menu_items={breadcrumbNav} />
	<div className='mainContentNoSidebar'>
		<h1>Dashboard</h1>
		//Main content here
	</div>
</Wrapper>

Then my wrapper component looks like the following:

function Wrapper(props) {

    const [session, loading] = useSession()

    if (!loading)
    {
        if (isSessionValid(session))
        {
            return (
                <div className='wrapper'>
                    <TopNav sessionValid={session !== null} usersName={session !== null ? session.user.firstName + ' ' + session.user.lastName : ''}/>
                    {props.children}
                </div>
            )
        }
        else
        {
            return (
                <div className='wrapper'>
                    <TopNav />
                    <NotLoggedIn />
                </div>
            )
        }
    }
    else
    {
        return null;
    }
}

From any API endpoints you have in the /pages/api section you can check the session is valid and returning a 403 if not using the following

import {getSession} from "next-auth/client";

export default async function handler(req, res) {
    
    const session = await getSession({req});
    if (!session)
    {
        res.status(403).end();
    }
    else
    {
    	//continue with api request
        res.status(200).end();
    }

I hope this post helps, I've only recently started looking into nextjs and nextauth so if you spot any issues or area where it can be improved then let me know in the comments.

Thanks

Chris