From Scratch: Crafting a Custom Auth System in Next.js

From Scratch: Crafting a Custom Auth System in Next.js

A Step-by-Step Walkthrough for Learning Authentication Basics with Server Actions

Introduction

Authentication is a cornerstone of modern web applications, ensuring secure access to data and enabling personalized user experiences. With the introduction of Server Actions in Next.js 13, the process of managing authentication has become more seamless. Server Actions allow developers to handle user sessions and security directly within their server-side logic, reducing the complexity of client-server interactions. This enables the creation of robust and efficient authentication systems.

In this article, we’ll explore how to implement authentication in Next.js using Server Actions.

But first, let’s define authentication: It’s the process of verifying a user’s identity, answering the question, "Who are you?" to ensure that the person accessing the application is who they claim to be.

Project Setup

Before we dive into authentication, let's create a new NextJS project. Open your command line and cd into the directory that you want to create your project. NextJS will create the project within a new folder inside your current directory.

Creating a new NextJS project

Run the following command and go through the process to create a new NextJS spp.

Note :- If you want to create the NextJS app inside the current directory instead of creating a new folder inside you current directory, you can enter "./" as the input when the install process asks for project name.

npx create-next-app@latest

Installing dependencies

Once the wizard is finished, cd into your newly created folder for your project.

Next you'll have to install the required dependencies for the app. Below are the dependencies that we are going to use to create the authentication app,

  • bcryptjs - for hashing passwords and to compare them

  • jose - to encrypt and decrypt JWT tokens

  • zod - to validate form inputs

As we need to save the user data, we'll have to use a database to manage the data. Here I am going to use firebase as the database and you can use whatever database you like. As in this article we are more focused on incorporating Authentication into our NextJS app. I am not going to mention more about the database configuration.

Run the following command to install the necessary dependencies and the required types.

npm install bcryptjs jose zod && npm i --save-dev @types/bcryptjs

Finally, to make sure everything is working well run your newly created NextJS project by running this command,

npm run dev

If all went well, you’ll be able to see a page similar to the below page when you visit http://localhost:3000/.

Creating a navbar

To streamline the login and logout process and maintain consistency throughout the application, we'll build a reusable Navbar component that can be easily integrated across multiple pages.

Let’s first create a new folder in our project to store all our application’s reusable components. So in the root of your project, create a new folder called /components. Inside it, create a new file Navbar.tsx, and write the Navbar code.

// app/components/Navbar.tsx

import React from 'react'
import Link from 'next/link'

const Navbar = () => {
  return (
    <nav className="flex justify-around p-3 border-b-2 border-indigo-500 items-center">
      <div>
        <Link href="/">Next Auth</Link>
      </div>
      <div className="flex space-x-5">
        <button className="py-2 px-4  bg-blue-500 rounded">
          <Link href="signup">Sign up</Link>
        </button>
        <button className="py-2 px-4 bg-blue-600 rounded">
          <Link href="login">Login</Link>
        </button>
      </div>
    </nav>
  )
}

export default Navbar

Next, to display the Navbar component on our web pages, we need to add it to the layout.tsx file. The layout file typically serves as a wrapper for the main content of the application, ensuring that the Navbar is consistently displayed across all pages.

// app/layout.tsx

import type { Metadata } from "next";
import "./globals.css";
import Navbar from "@/components/Navbar";

export const metadata: Metadata = {
  title: "Next Authentication",
  description: "Next Auth app created from scratch",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        <div className="flex h-screen flex-col">
          <Navbar />
          <main className="flex-1">{children}</main>
        </div>
      </body>
    </html>
  );
}

Clear the boilerplate code in page.tsx, customize it as you like. And now you will see our new component at the top of any page you visit.

Signup user

Authentication usually starts with a signup process. First, we need to create a signup form to collect user inputs. Then, we need to pass and save these inputs in the database. We will use server actions to handle user inputs and save the data.

Signup page

In your /app folder, create a new folder called /signup and create a page.tsx file inside it. Here we are goin to capture user credentials by creating a form that invokes a Server Action on submission. Below signup form accepts the user's name, email, and password as inputs.

// app/signup/page.tsx

import { signup } from '@/app/actions/auth'

export function SignupForm() {
  return (
    <form action={signup}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" placeholder="Name" />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" placeholder="Email" />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
      </div>
      <button type="submit">Sign Up</button>
    </form>
  )
}

export default SignupForm

The above form does not include any styles. You can style your form however you like. I added some basic styles, and here is how my form looks like,

Signup actions

Create a new folder called /actions in the root directory, and inside it, create a new file called auth.ts to add the server actions that can be called from the signup page. This server action will take form state and form data as parameters. We’ll define the types of these parameters in the next section. The basic structure of a signup action will look like this:

// actions/auth.ts

"use server"

export async function signup(state, formData) {
  // 1. Validate form fields

  // 2. Save user in db

  // 3. Create session
}

1. Validate form fields

Form validation can be done in different ways, here we are using Zod, but you can use whatever method you prefer.

  1. Define a form schema

    Create a new folder /lib in the root directory and inside there, create a file called definitions.ts. Here we define the form schema with the appropriate error messages, and the form state types as I mentioned above.

     // lib/definitions.ts
    
     import { z } from 'zod'
    
     export const SignupFormSchema = z.object({
       name: z
         .string()
         .min(2, { message: 'Name must be at least 2 characters long.' })
         .trim(),
       email: z.string().email({ message: 'Please enter a valid email.' }).trim(),
       password: z
         .string()
         .min(8, { message: 'Be at least 8 characters long' })
         .regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' })
         .regex(/[0-9]/, { message: 'Contain at least one number.' })
         .regex(/[^a-zA-Z0-9]/, {
           message: 'Contain at least one special character.',
         })
         .trim(),
     })
    
     export type FormState =
       | {
           errors?: {
             name?: string[]
             email?: string[]
             password?: string[]
           }
           message?: string
         }
       | undefined
    
  2. Update actions

    Head over to the actions file and update the signup function by defining types and validating the form inputs. To prevent unnecessary calls to our database, we can return early in the Server Action if any form fields do not match the defined schema.

     // actions/auth.ts
    
     "use server"
    
     import { SignupFormSchema, FormState } from '@/app/lib/definitions'
    
     export async function signup(state: FormState, formData: FormData): Promise<FormState> {
       // 1. Validate form fields
       const validatedFields = SignupFormSchema.safeParse({
         name: formData.get('name'),
         email: formData.get('email'),
         password: formData.get('password'),
       })
    
       if (!validatedFields.success) {
         return {
           errors: validatedFields.error.flatten().fieldErrors,
         }
       }
    
       // 2. Save user in db
    
       // 3. Create session
     }
    
  3. Update the sign up page

    Back in the <SignupForm />, we can utilize React's useFormState hook to manage and display validation errors as the form is being submitted. You can also customize the appearance of these error messages to match your application's design. For instance, you might want to highlight the input fields with errors or display the messages in a specific color. However, as we are more focused on functionality, I will not delve into styling these error messages in this example. Instead, we will concentrate on ensuring that the validation logic works correctly.

     // app/signup/page.tsx
    
     'use client'
    
     import { useFormState } from 'react-dom';
     import { signup } from '@/app/actions/auth'
    
     export function SignupForm() {
       const [state, action] = useFormState(signup, undefined);
    
       return (
         <form action={action}>
           <div>
             <label htmlFor="name">Name</label>
             <input id="name" name="name" placeholder="Name" />
           </div>
           {state?.errors?.name && <p>{state.errors.name}</p>}
    
           <div>
             <label htmlFor="email">Email</label>
             <input id="email" name="email" type="email" placeholder="Email" />
           </div>
           {state?.errors?.email && <p>{state.errors.email}</p>}
    
           <div>
             <label htmlFor="password">Password</label>
             <input id="password" name="password" type="password" />
           </div>
           {state?.errors?.password && (
             <div>
               <p>Password must:</p>
               <ul>
                 {state.errors.password.map((error) => (
                   <li key={error}>- {error}</li>
                 ))}
               </ul>
             </div>
           )}
    
           {state?.message && <p>{state?.message}</p>}
    
           <button type="submit">Sign Up</button>
         </form>
       )
     }
    
     export default SignupForm
    

If you now submit the form with invalid inputs, you will see the error messages that we defined in definitions.ts under the SignupFormSchema.

2. Save user in database

Once the form validation is complete, the next step is to save the user in a database. As I mentioned before, I won't go into detail about the database process because our main focus is on Next.js authentication. But before saving the user in the database, hash the password using the bcrypt library that we installed previously. In my code, I included a simple logic to save the user by hashing the user password.

"use server"

import { db } from "@/config/firebase";
import {
  collection,
  addDoc,
} from "firebase/firestore";

export async function signup(state: FormState, formData: FormData): Promise<FormState> {
  // 1. Validate form fields
  // ...

  // 2. Save user in db
  const { name, email, password } = validatedFields.data;
  const hashedPassword = await bcrypt.hash(password, 10);

  const user = await addDoc(collection(db, "users"), {
    name: name,
    email: email,
    password: hashedPassword,
  });

  if (!user.id) {
    return {
      message: "An error occurred while creating your account.",
    };
  }

  // 3. Create session
}

If you want you can check if the user already exists by querying your database. If the user does not exist, you can proceed to create a new user. This involves validating the user data, such as the name, email, and password. After validation, you should hash the password for security purposes before storing it in the database. Once the password is hashed, you can then add the new user to the database. If the user creation is successful, you will receive a user ID. If there is an error during this process, you should handle it appropriately by returning a meaningful error message to the user. This ensures that the user is informed if something goes wrong while creating their account.

3. Create session

After successfully creating the user account, we can create a session to manage the user's auth state. The session can be stored either in a cookie or database, or both.

There are two types of sessions:

  1. Stateless: Session data (or a token) is stored in the browser's cookies. The cookie is sent with each request, allowing the session to be verified on the server. This method is simpler, but can be less secure if not implemented correctly.

  2. Database: Session data is stored in a database, with the user's browser only receiving the encrypted session ID. This method is more secure, but can be complex and use more server resources.

This article is going to show how to create a stateless session. To create and manage stateless sessions, there are a few steps you need to follow:

  1. Generate a secret key.

  2. Write logic to encrypt/decrypt session data.

  3. Manage cookies using the Next.js cookies() API.

1. Generating the secret key

There are a few ways we can generate secret key to sign our session. One way is to generate a key by using the openssl command. This command generates a 32-character random string that you can use as your secret key.

openssl rand -base64 32

Run this command in the terminal and copy the generated key and store it in the environmental variables file.

// .env

SESSION_SECRET=your_secret_key

2. Writing the logic

Next, we need to write the encrypt and decrypt functions. Here, we are going to use jose library for encryption and decryption, and React's server-only package to ensure that our session management logic runs only on the server.

// lib/session.ts

import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { SessionPayload } from '@lib/definitions'

const secretKey = process.env.SESSION_SECRET
const encodedKey = new TextEncoder().encode(secretKey)

export async function encrypt(payload: SessionPayload) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(encodedKey)
}

export async function decrypt(session: string | undefined = '') {
  try {
    const { payload } = await jwtVerify(session, encodedKey, {
      algorithms: ['HS256'],
    })
    return payload
  } catch (error) {
    console.log('Failed to verify session')
  }
}

In the definition.ts file, define the data types of the payload.

export type SessionPayload = {
  userId: string | number;
  expiresAt: Date;
};

3. Manage cookies

To store the session in a cookie, we can use the Next.js cookies() API. The cookie should be set on the server, and include the recommended options:

  • HttpOnly: Prevents client-side JavaScript from accessing the cookie.

  • Secure: Use https to send the cookie.

  • SameSite: Specify whether the cookie can be sent with cross-site requests.

  • Max-Age or Expires: Delete the cookie after a certain period.

  • Path: Define the URL path for the cookie.

Open the session file and let’s write a new function to create a session by including the recommended options.

// lib/session.ts

// ...
import { cookies } from "next/headers";

// ...

export async function createSession(userId: string) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
  const session = await encrypt({ userId, expiresAt });

  cookies().set("session", session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: "lax",
    path: "/",
  });
}

Now head back to the server actions. Here we can invoke the createSession() function and create the session. After that, redirect() function can be used to redirect the user to the appropriate page.

// actions/auth.ts

//...
import { redirect } from 'next/navigation';
import { createSession } from "../lib/session";

export async function signup(state: FormState, formData: FormData): Promise<FormState> {
  // 1. Validate form fields
  // ...

  // 2. Save user in db
  // ...

  // 3. Create session
  await createSession(user.id.toString());
  redirect("/protected");
}

Login user

It’s time to create the login process for the user. After a user has successfully registered, they need to log in to the web app. This process involves validating the user's credentials, creating a session, and redirecting them to the appropriate page within the application.

Login page

In the /app folder, create a new folder called /login and create a page.tsx file inside it. Here we are going to capture user credentials by creating a form that invokes a Server Action on submission. Below login form accepts the user's email, and password as inputs.

// app/login/page.tsx

"use client";

import { useFormState } from "react-dom";
import { login } from "@/actions/auth";

export function LoginForm() {
  const [state, action] = useFormState(login, undefined);

  return (
    <form action={action}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" placeholder="Email" />
      </div>
      {state?.errors?.email && <p>{state.errors.email}</p>}

      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
      </div>
      {state?.errors?.password && (
        <div>
          <ul>
            {state.errors.password.map((error) => (
              <li key={error}>- {error}</li>
            ))}
          </ul>
        </div>
      )}

      {state?.message && <p>{state?.message}</p>}

      <button disabled={pending} type="submit">
        Login
      </button>
    </form>
  );
}

export default LoginForm;

The above form does not include any styles. You are free to style your form as you prefer. I have added some basic styles, and here is the appearance of my form,

Login action

Head back to the actions file, and let’s create the server actions which are necessary for the login process.

// actions/auth.ts

import { collection, addDoc, query, where, getDocs } from "firebase/firestore";
import {
  // ...
  LoginFormSchema
} from "@/lib/definitions";

export async function loginUser(state: FormState, formData: FormData) {
  // 1. Validate form fields
  const validatedFields = LoginFormSchema.safeParse({
    email: formData.get("email"),
    password: formData.get("password"),
  });

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }

  // 2. Validate credentials
  const { email, password } = validatedFields.data;
  const userQuery = query(collection(db, "users"), where("email", "==", email));
  const querySnapshot = await getDocs(userQuery);

  if (querySnapshot.empty) {
    return { message: "Invalid email or password" };
  }

  let userData: any;
  let userId: string | undefined;
  querySnapshot.forEach((doc) => {
    userData = doc.data();
    userId = doc.id;
  });

  if (!userData || !userId) {
    return { message: "Invalid email or password" };
  }

  const isCorrectPassword = bcrypt.compareSync(password, userData.password);

  if (!isCorrectPassword) {
    return { message: "Invalid email or password" };
  }

  if (!userId) {
    throw new Error("User ID is undefined");
  }

  // 3. Create session
  await createSession(userId.toString());
  redirect("/protected");
}

Here’s how the LoginFormSchema looks like:

export const LoginFormSchema = z.object({
  email: z.string().email({ message: "Please enter a valid email." }).trim(),
  password: z
    .string()
    .min(1, { message: "Password cannot be empty" })
    .trim(),
});

Logging out the user

To log out the user, we need to delete the session. To do this, we have to update the session and action files.

Delete session

// lib/session.ts

//...

export function deleteSession() {
  cookies().delete('session');
}

Create logout() function

The deleteSession() function can be reused in the application for the logout function.

// actions/auth.ts

//...
import { deleteSession } from '../lib/session'

//...
export async function logout() {
  deleteSession();
  redirect('/login');
}

To show a logout button for the user, we need to know if the user is authenticated. This helps us decide whether to display a Login button or a Logout button.

To check if the user is authenticated, we can look for the session cookie using cookies(). This function can only be imported in server components. So, let's import it in the Navbar component and pass it as a prop to the LogoutButton component, which we are going create next.

// app/components/Navbar.tsx

import React, { useState, useEffect } from "react";
import Link from "next/link";
import { cookies } from "next/headers";
import LogoutButton from "./LogoutButton";

const Navbar = () => {
  const session = cookies().get("session");

  return (
    <nav className="flex justify-around p-3 border-b-2 border-blue-500 items-center">
      <div>
        <Link href="/">Next Auth</Link>
      </div>
      <LogoutButton sessionToken={session?.value || null} />
    </nav>
  );
};

export default Navbar;

Create a new component called LogoutButton inside the /components folder, and take the prop from the Navbar. We have to make the LogoutButton component a client component because we are going to use some hooks and event handlers that can only be used inside client components.

// app/components/LogoutButton.tsx

"use client";

import React, { useState, useEffect } from "react";
import Link from "next/link";
import { logoutUser } from "@/actions/auth";

interface LogoutButtonProps {
  sessionToken: string | null;
}

const LogoutButton: React.FC<LogoutButtonProps> = ({ sessionToken }) => {
  const [isAuth, setIsAuth] = useState(false);

  useEffect(() => {
    if (sessionToken) {
      setIsAuth(true);
    } else {
      setIsAuth(false);
    }
  }, [sessionToken]);

  const logout = async () => {
    await logoutUser();
    setIsAuth(false);
  };

  return (
    <div>
      {!isAuth ? (
        <div className="flex space-x-5">
          <button className="py-2 px-4  bg-blue-500">
            <Link href="signup">Sign up</Link>
          </button>
          <button className="py-2 px-4 bg-blue-600">
            <Link href="login">Login</Link>
          </button>{" "}
        </div>
      ) : (
        <button className="py-2 px-4 bg-blue-600" onClick={logout}>
          Logout
        </button>
      )}
    </div>
  );
};

export default LogoutButton;

Protected route

After a successful login of a user, we can restrict pages for non-logged in users and allow access only to the logged in users by creating a middleware and redirecting users based on permissions.

Let’s create a new folder called protected in the app directory and inside there create a page.tsx.

import React from 'react'

const page = () => {
  return (
    <div>Welcome to protected page</div>
  )
}

export default page

At the moment, any user can access this page. Let’s make it accessible only to logged-in users.

Create a new file in the root directory called middleware.ts and add the below code.

import { NextRequest, NextResponse } from "next/server";
import { decrypt } from "@/lib/session";
import { cookies } from "next/headers";

const protectedRoutes = ["/protected"];
const publicRoutes = ["/login", "/signup"];

export default async function middleware(req: NextRequest) {
  const path = req.nextUrl.pathname;
  const isProtectedRoute = protectedRoutes.includes(path);
  const isPublicRoute = publicRoutes.includes(path);

  const cookie = cookies().get("session")?.value;
  const session = await decrypt(cookie);

  if (isProtectedRoute && !session?.userId) {
    return NextResponse.redirect(new URL("/login", req.nextUrl));
  }

  if (
    isPublicRoute &&
    session?.userId &&
    !req.nextUrl.pathname.startsWith("/protected")
  ) {
    return NextResponse.redirect(new URL("/protected", req.nextUrl));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"],
};

Now only logged in users can access the pages in the protected route. If a logged in user try to view the publicRoutes they will be redirected to the protected page. For more authorization methods, you can check the official Next.js documentation.

Now you’ll have a fully functioning Next.js authentication app made with server actions.

Thank you

Before we wrap up, I want to remind you that implementing secure authentication is crucial for any application. While the example provided gives a basic overview, there are more advanced methods and libraries available that can enhance the security of your app. Libraries like NextAuth.js, OAuth, and Clerk offer robust solutions for handling authentication and can save you a significant amount of development time.

If you're interested in exploring more about authentication and authorization in Next.js, I highly recommend checking out the official Next.js documentation. It provides comprehensive guides and best practices that can help you build more secure and efficient applications.

Thank you for reading this far. If you’ve found it helpful or would like me to cover something else, let me know in the comments.