How To Build A Frameworkless Authentication System For Next.js

Publish Date - 1st June 2022

Last Updated - 5th March 2023

I tend to stay away from frameworks, most of the time.

That sounds weird because most of my JavaScript projects are in Next.js/React.js. As a developer, I am not always in charge of the architectural decisions. If the techlead or CEO wants to use a framework, then a framework we will use.

When I was running Frosty Response, I often chose to not use frameworks because there are too many unknowns (things that I cannot be sure of) that happen in the background (ever had a hydration error?).

There is another issue with frameworks. That is when a framework requires another framework. This is something I call a multi-level frameworks issue.

That is when a framework - like Next.js - have require another framework for something specific. In this specific case, Next_Auth is the authentication framework used by 90% of Next.js apps.

NextAuth not fully support email and password authentication. They make it intentionally difficult so that the user is shoe horned into using OAuth 2.0

As a user of tools, I understand opinionated software. As a developer, I do not understand opinionated development tools. I like to have the freedom to make micro changes should the need arise.

So I developed a frameworkless system for authenticating users in Next.js, without nextauth.

Note - This flow uses TypeScript. Note2 - I will ommit imports and exports from the examples Note3 - I am assuming you have a database with usernames and passwords already


  • Setting Up The Application For Authentication
  • Creating the Global State for Authentication with Context API
  • Building the Sign In Form
  • Creating the Verification API Route
  • Important Connections Between These Files
  • Creating the Protected Route
  • Wrapping the Application with the Protected Route and Auth Contect Provider

NOTE: Authentication is a complex topic. As I learn more about security and authentication best practice, this process will be updated.

Setting Up The Application for Authentication

There are two main steps to setting up this authentication process.

Firstly, the packages. Secondly the types.


When you install jsonwebtoken, you also need to install the types

project>$ npm install jsonwebtoken
project>$ npm install --save @types/jsonwebtoken

Setting up types for the project

Let's create the types for the entire project in one file. We will be using these types throughout the script.

Create a file called types.tsx

//for the Auth Context
export interface AuthContextInterface {
  token: string | null;
  isLoggedIn: boolean;
  login: (token: string) => void;
  logout: () => void;

//for wrapper components
export interface childrenProps {
  children: React.ReactNode

//For ProtectedRoute wrapper component
interface ProtectedRouteProps {
  children: React.ReactElement;

// for the submit form
export interface eventType {
  (e: React.FormEvent<HTMLFormElement>): void;

// for the login information sent from the signin form
export interface login {
  username: string;
  password: string;

Creating the Global State for Authentication with Context API

Create a file called AuthContext.tsx.

InitialToken Variable

Firstly, we need to create a single variable to hold the state of our initial token value. This initialToken value will be passed into our state later.

let initialToken: string | null

This must be a union type with null because initialToken will be getting its updated state from localStorage.getitem() function . Which could be null. More on this later.

Create Context

In this file we will be creating the global state for authentication. This will include:

  • the jwt token
  • the login boolean state
  • the login function, and,
  • the logout function.

For these variables,we need to create our initial values and pass them into the createContext function.

Create the AuthContext variable and assign it to the createContext function. This function takes an object which will be the initial values for the variables defined above:

export const AuthContext = createContext<AuthInterface>({
  token: '',
  isLoggedIn: false,
  login: (token) => {},
  logout: () => {},

This should be exported because it will be used from outside of this file.

The AuthContextProvider

Finally, we create the AuthContextProvider which will be used as a wrapper. This will wrap around our application in _app.js. More on that later.

I have marked up the code to explain each part:

export const AuthContextProvider = (props: UserProviderProps) => {
  //our initial state using the initialToken variable created earlier
  const [token, setToken] = useState(initialToken);

  // a variable to define if the user is loggedin or not.
  // login is defined by having a present jwt token.
  // more on jwt later
  let userIsLoggedIn: boolean;

  // check if there is a token. return boolean value
  if (token) {
    userIsLoggedIn = true;
  } else {
    userIsLoggedIn = false;

  useEffect(() => {
    // on load, check for a token in local storage
    initialToken = localStorage.getItem('token');
	// set the token to the value of the token
	// run once on load
  }, []);

  const logoutHandler = () => {

  //save the token to the local storage
  // we will use this function later in the login form
  const loginHandler = (token: string) => {
    // also update the state to the new token value
    localStorage.setItem('token', token);

  // create context value object whose value is to be made available to all components using this context.
  const contextValue = {
    token: token,
    isLoggedIn: userIsLoggedIn,
    login: loginHandler,
    logout: logoutHandler,

  return (
    <AuthContext.Provider value={contextValue}>

export default AuthContext;

Our AuthContext is now ready to use. Next, we need our sign in form.

Building a Sign In Form

The form I will be using is the is a standard form so I will not focus on that. Here, I will focus on the important parts: the submitHandler and AuthContext.

Create a file called SignInForm.tsx. This is a Next.js page route.

First, in our form page component, we use our AuthContext via useContext. This is the most important part that connects the Auth context to the sign in form:

export default function SignInForm() {


 const authCtx = useContext(AuthContext);


We then add the state variables for the username and login:

export default function SignInForm() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');


  const authCtx = useContext(AuthContext);


We also need to use the useRouter to redirect the user to a page after login.

Now, initialise the router:

export default function SignInForm() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const router = useRouter();

  const authCtx = useContext(AuthContext);

  // submit handler will be here

Now, we need to create the submitHandler function that triggers when the form is submitted. I have marked up the code to explain each step:

const submitHandler: eventType = async function (e) {

  //create object for submited state.
  //this is updated by the form on submit.
  const loginDetails: login = {

  // fetch - post username and password to api
  // we will look at the API in the next section
  await fetch('/api/SignIn', {
    method: 'post',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
	//send the login info as a string to the api route
    body: JSON.stringify(loginDetails),
    // convert response to JSON
    .then((data) => data.json())
	// use response
    .then((data) => {
      //run authcontext login function with the token send from the backend API
      /* console.log(data); */

To summarise where we are so far:

  • We have a global context called AuthContext that holds the login function and the current login state based on if we have a token or not.
  • We have a sign in form that takes the username and password submitted by the user. This is sent to the backed API for verification. If verified, a token is sent back and saved to local storage

So, lets create the API route.

Creating the Verification API Route

There are some benefits to frameworks. One of them is the /api folder in Next.js. It allows you to create api routes (Node.js) code directly inside the project. The routes created inside the api folder are instantly available depending on the name of the file.

If you notice, in the SignInForm.tsx (the previous step) we sent the post request to /api/SignIn. That is what we will create now.

In this example, create a file called SignIn.tsx.

The TypeScript function for the api handler is:

export default async function Handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  try {
   if (req.method === 'POST') {

	// all code below will be inside here
  } catch (err) {

First we access the post request body that was sent in. And validate that the username or password are not empty:

// Get user input with types
    const { username, password }: login = req.body;

    // Validate user input
    if (!(username && password)) {
     res.status(400).send('All input is required');

Then, we use our database search function to get the user assigned to that username. Here, I am assuming you have your own function for this. This script is database agnostic. Whether you use PostgreSQL, a JSON file or MongoDB, it doesnt matter. Just search for a user and put that function here:

// Validate if user exist in our database
    const user = getOneUser(username);

If user not found, send error:

if (user === undefined) {
      res.status(400).json({ res: 'no user' });

If a user is found, verify the password. This part is important. Password encryption (hashing) and verification is performed by a package called bcryptjs. I will write a seperate article about password encryption and verification. For now, assume this function returns a true or false depending of if the sent in password is matches the records:

  const isValid = await verifyPassword(password, user.password);

Finally, if the token is value (true) then sign the token with jwt and add it to the user object. Then return the token in the response back to the post request:

    if (isValid) {
      // Create token
      const token = sign(
          username: username,
      //add token to user object
      user.token = token;

      res.status(200).json({ token: user.token });

What is happening here?

Our post API received the username and password (login details) from a post request. The login details is matched to the database to find a user. If a user is found, the password is verified. If the password is verified the token is signed (created) and sent back as a response to the post request.

Important Connections Between These Files

Inside the SignInForm.tsx, we send a post request to the backend API. Inside the second then() block of the post request, we use the AuthCtx.login() function. This function takes data.token.

data.token comes from the key value pair sent back to the post request as a response:

  res.status(200).json({ token: user.token });

The AuthCtx.login() function then saves the token to localStorage:

  const loginHandler = (token: string) => {
    localStorage.setItem('token', token);

When a page is loaded, the useEffect() function inside the AuthContext.tsx file runs. It sets the initialToken value to what is in the localStorage:

useEffect(() => {
    initialToken = localStorage.getItem('token');
  }, []);

When this runs, the initialToken variable is set to a new value. This updates the state.

The login state variable userIsLoggedIn value is then retested in the same file. Now that there is a token, it will be set to true:

  if (token) {
    userIsLoggedIn = true;
  } else {
    userIsLoggedIn = false;

Creating The Protected Route

The final file we need to create is a wrapper component that protects the entire application. This wrapper runs the authentication checks defined above for every component it wraps.

Created a file calleed ProtectedRoute.tsx

export default function ProtectedRoute({ children }: ProtectedRouteProps) {
  const authCtx = useContext(AuthContext);
  const isLoggedIn = authCtx.isLoggedIn;

  if (isLoggedIn == false) {
    return (
        <div className="plain-page">
          <SignInForm />
  } else {
    return children;

in this file, we are returning the sign up box if not loggedin, or the children components. This is a wrapper component, therefore the children will be based on the path we visit.

Wrapping the Application with the Protected Route and Auth Contect Provider

Finally, We need to wrap _app.js (the entire app) with our AuthContextProvider and the ProtectedRoute wrapper component.

export default function App({ Component, pageProps }: AppProps) {
  return (
          <Component {...pageProps} />

And we are done. Frameworkless authentication is completed. We can use username and passwords to create authentication.

Bye for now.