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.

About the Author
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.