logo
Building Custom Authentication in Next JS
All about NextJS authentication using http-only cookies and managing server side sessions.

OEOsama Ehsan Qureshi
Share this on:
last modified: May 1, 2025

Sometimes it’s nice to have a third party, such as Clerk, handle the user authentication flow for us. This can result in a minimal-e

ffort setup and easy user management. But what if we want complete control over how things look and function? Moreover, what if we want to know exactly what happens behind the scenes? Well, then we can build our authentication system.

My goal is to teach you the logic behind every line of code we’ll write together. So, throughout this detailed tutorial, we’ll be thinking critically about what makes sense to happen. Let’s jump into it 🚀

Here’s what we will cover:

  • Custom Sign Up
  • Custom Log In
  • Middleware Route Protection
  • User Retrieval
  • Log Out Function

Assumptions:

The tutorial assumes you have a basic knowledge of Firebase, Next JS, and React. We won’t dive into the details of connecting Firebase, so please refer to these docs if needed. We’ll be using JWT and cookies, don’t worry if you’re unfamiliar with them — I’ll cover them here.

Building a Custom Sign Up (Frontend)

Let’s tackle the frontend first. Here’s how my route structure is organized /app/(auth)/signup/page.tsx. In Next.js, to create a folder solely for organization, we wrap it in parentheses.

The frontend is pretty self-explanatory, but I’ll leave some comments inside the code.

If your goal is to learn, I strongly suggest you NOT copy any code 💁‍♀️. Instead, focus on understanding the logic behind it. Once you grasp the logic you won’t need to memorize anything.

"use client"; //this component renders on the client side

export default function SignUp() {
const router = useRouter(); //router for redirecting the user
  const fields = [ //we'll map over our fields to avoid code repetition
    {
      name: "name",
      type: "text",
    },
    {
      name: "email",
      type: "email",
    },
    {
      name: "password",
      type: "password",
    },
  ];
  const [data, setData] = useState({
    name: "",
    email: "",
    password: "",
  }); //initializing our data
  const onChange = (e) => { //simultaneously handling input changes
    const { name, value } = e.target; //destructuring
    setData((prevState) => ({
      ...prevState, //creating an instance copy with the spread operator
      [name]: value, //for each name there's a value
    }));
  };
    const onSubmit = async (e) => {
    e.preventDefault();
    try {
    //we'll create our API endpoint in the next step
      const response = await axios.post("/api/signup", data); //sending data
      if (response.status === 200) { //if success then redirect
        router.push("/login");
      }
    } catch (err) {
      console.log(err); //ofc error handling should be stronger IRL
    }
  };
  return (
    <>
      <div className="flex w-full h-full justify-center items-center">
        <form
          onSubmit={onSubmit}
          className="p-4 flex flex-col gap-4 border w-fit m-4 rounded-md"
        >
          {fields.map((field, i) => (
            <input
              key={i}
              className="outline-none"
              placeholder={field.name}
              name={field.name}
              type={field.type}
              onChange={onChange}
            />
          ))}
          <button className="hover:bg-slate-100 border py-2 px-8 rounded-md">
            sign up
          </button>
        </form>
      </div>
    </>
  );
}

Building a Custom Sign Up (Backend)

Next.js is a fullstack framework, meaning it allows us to handle frontend and backend all in one place. That’s why our backend lives in a folder called api. In Next.js, to send a request from our client (frontend), we need to create an API endpoint that will receive it, process it, and send back a response.

Having said that, the structure for our server (backend) is going to look like this: /app/api/(auth)/signup/route.js. Notice how our server has a route.js inside it, contrary to page.js which is for client-side only.

Let’s think, or better even reason, about what needs to happen:

  • In Next JS a backend function must be named after the request type. We are sending (posting) some pieces of data from client to server, so our request type is POST. We also want to handle errors nicely, so a try and catch block around our main code would be of help.
  • We need to extract the name, email, and password information. We are requesting this information using our server, therefore it’s going to come from our request body.
export async function POST(req) {
  const { name, email, password } = await req.json() //awaiting our JSON

  try {
    //our code will be here...
  }
  catch (err) {
    return NextResponse.json({ error: err.message }, { status: 500 });
  }
}
  • It would now make sense to check if the user with the email we received from our client already exists in the database, right? Right. So we’ll have to query a specific collection in our database where the user’s email is equal to the email we got from our client. See? That’s our function right there!
  const userQuery = query(collection(db, "users"), where("email", "==", email));
  const querySnapshot = await getDocs(userQuery); //all docs that match our query
  if (!querySnapshot.empty) { //if there's any that matched
    return NextResponse.json( //we return a response
      { error: "User already exists!" }, //saying that the user already exists
      { status: 400 }
    );
  }
  • So we handled the case above. Now, what if the user doesn’t exist? We need to create one, in other words we need to add a document (think addDoc). But before that it’d be nice to hash our password. We can easily do it with bcrypt. We know that we need to hash and salt (add a random string to our hash multiple times) our password, so let’s do it.
//...install and import bcrypt

const salt = await bcrypt.getSalt(10)
const hashedPassword = await bcrypt.hash(password, salt)
  • Ok, done! Let’s finally add a new user to our users collection, shall we?
  const newUser = await addDoc(collection(db, "users"), { //passing our data
    name: name, 
    email: email,
    password: hashedPassword
  })
  • It would be useful to know if our user has been successfully created, and if not it would be just as nice to handle the case where it wasn’t. How can we check if the user was added? I think we can do it by checking if the document we had just created exists.
 const newUserDoc = await getDoc(newUser); //getting a specific doc
    if (newUserDoc.exists()) { 
      return NextResponse.json({
        message: "User successfully created",
        success: true,
        data: newUserDoc.data(), //returning our user data
      }, {status: 200});
    }

Give yourself a pat on the back — you nailed the /api/signup route! Now, go ahead and try to fill out the form and console log its response, you should see a success message. You can also just check your Firebase to see if the user dropped in there.

sample console output

About the Author

OE
Osama Ehsan Qureshi

Osama is a senior web developer with over 10 years of experience building modern web applications.

Specializing in React, Next.js, and TypeScript, Osama has helped numerous startups scale their frontend architecture.

When not coding, you can find Osama hiking or writing technical articles on Medium.

Related Blogs