Next JS Authentication With Firebase - Do It Right

In this video, you will learn how to implement Next.js authentication with Firebase.

Do we need Firebase?

The first question is whether we need Firebase at all. We are developing our React application with Next.js and need to store some data on the backend. If we don't know much about backend development, APIs, or servers, we might want to use Firebase instead.

With Firebase, you can create an account, generate some data through the UI, and then use it through a library on your client.

So, without any additional backend knowledge, you can do that.

Our first step is to register on Firebase and create a project.

firebase project

Let's name our project next, hit continue, and create the project.

sidebar

Here, we need to look in the left sidebar, select "Authentication," and click "Get started." Now, we need to select our sign-in providers. Firebase supports many of them. For the purpose of this post, we will use "Email/Password" authentication, so let's enable it.

new user

On the "Users" tab, we can create a new user directly inside Firebase. As a result, we have created our first user and saved it to the database.

The Project

Now, let's look at our application.

project

It is built with Next.js and has several links at the top of the page. We also have "Register" and "Login" pages, as well as a logout button.

// src/app/register/page.js
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

const Register = () => {
  const router = useRouter();
  const [username, setUsername] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const onSubmit = async (e) => {
    e.preventDefault()
    console.log('onSubmit')
  };

  return (
    <div>
      <h1>Register</h1>
      <form onSubmit={onSubmit}>
        <div>
          <input
            type="text"
            placeholder="Username"
            onChange={(e) => setUsername(e.target.value)}
          />
        </div>

        <div>
          <input
            type="text"
            placeholder="Email"
            onChange={(e) => setEmail(e.target.value)}
          />
        </div>
        <div>
          <input
            type="password"
            placeholder="Password"
            onChange={(e) => setPassword(e.target.value)}
          />
        </div>
        <div>
          <button type="submit">Sign Up</button>
        </div>
      </form>
    </div>
  );
};

export default Register;

Here is how our register component looks. It's just a form that doesn't do anything. Exactly the same as what we have inside the login page.

Setting up Firebase

In order to start using Firebase, we must install a library for it.

npm i firebase

Our next step is to save all API keys from Firebase in our application. We want to do it in the correct way because Next.js provides an amazing solution to store environment variables. This is why in my project, I want to create an .env.local file.

Before we can do that, we must create an application inside Firebase.

firebase app

We can name our application "next", and after creation, we will receive numerous API keys from Firebase that we must use.

// .env.local
apiKey=...
authDomain=...
projectId=...
storageBucket=...
messagingSenderId=...
appId=...

Inside my environment file, I've placed all these variables, ensuring that we won't include them in the Git repository. Now, across the whole application, we can use these variables.

Our next step is to expose these variables on the client side in Next.js.

// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  env: {
    apiKey: process.env.apiKey,
    authDomain: process.env.authDomain,
    projectId: process.env.projectId,
    storageBucket: process.env.storageBucket,
    messagingSenderId: process.env.messagingSenderId,
    appId: process.env.appId,
  },
};

export default nextConfig;

As you can see, we are reading all these variables from process.env.

Now, we can create a new file in our src directory, which will be responsible for working with Firebase.

// src/app/firebase.js
const firebaseConfig = {
  apiKey: process.env.apiKey,
  authDomain: process.env.authDomain,
  databaseURL: process.env.databaseURL,
  projectId: process.env.projectId,
  storageBucket: process.env.storageBucket,
  messagingSenderId: process.env.messagingSenderId,
  appId: process.env.appId,
};

Now, the entire configuration is available both on the server and on the client. After this, we need to initialize our Firebase application.

// src/app/firebase.js
import { initializeApp } from "firebase/app";
import {getAuth} from "firebase/auth";

const firebaseConfig = {
  ...
};

const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);

Here, we not only created a Firebase app but also an auth object, which we will use across the application.

We use app for any Firebase application, but auth only when we want to use Firebase authentication.

User Register

This is enough for us to create our first function. The first thing we want to do is to register a user.

// src/app/firebase.js
...
export const register = (email, username, password) => {
  return createUserWithEmailAndPassword(auth, email, password).then(
    (response) => updateProfile(response.user, { displayName: username })
  );
};

We call createUserWithEmailAndPassword, but it is not possible to provide a username while registering a user. This is why we call updateProfile after user creation. Essentially, our register function has two promises, one after another.

Our register method is ready. Let's use it now.

// src/app/register/page.js
const Register = () => {
  ...
  const onSubmit = async (e) => {
    e.preventDefault();
    try {
      await register(email, username, password);
      router.push("/");
    } catch (err) {
      console.log(err)
    }
  };
}

When the register form is submitted, we call our register method. Upon success, we redirect a user to the homepage. If we get an error, we just log it.

register with validation error

If we provide incorrect data, we are getting a validation error in the console. When the data is correct, we are redirected to the home page, and the user is created.

register succ

If we open the admin panel of Firebase, we can see that a user was successfully created.

Data Validation

As you can see, our registration works, but we don't show a validation message to the user. We just log it to the console, but it's not enough.

We want to save the error from the backend and render it on the screen.

// src/app/register/page.js
const Register = () => {
  ...
  const [errorMessage, setErrorMessage] = useState(null);
  const onSubmit = async (e) => {
    e.preventDefault();
    try {
      ...
    } catch (err) {
      setErrorMessage(err.code);
    }
  };

  return (
    <div>
      <h1>Register</h1>
      {errorMessage && <div>{errorMessage}</div>}
      <form onSubmit={onSubmit}>
        ...
      </form>
    </div>
  );
};

export default Register;

Here, we created an errorMessage state and set it when we get an error from the backend. We also rendered it in the markup.

validation error

Now, when we get an error message, it is rendered on the screen.

Login

Now, we want to do exactly the same for the login page. But first, we need to create a login function for Firebase.


// src/app/firebase.js
...
export const login = (email, password) => {
  return signInWithEmailAndPassword(auth, email, password);
};

Here, we simply call a Firebase function to authenticate a user by email and password. Now it's time to use it in our login component.

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { login } from "../firebase";

const Login = () => {
  const router = useRouter();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [errorMessage, setErrorMessage] = useState(null);
  const onSubmit = async (e) => {
    e.preventDefault();
    try {
      await login(email, password);
      router.push("/");
    } catch (err) {
      setErrorMessage(err.code);
    }
  };

  return (
    <div>
      <h1>Login</h1>
      {errorMessage && <div>{errorMessage}</div>}
      <form onSubmit={onSubmit}>
        <div>
          <input
            type="text"
            placeholder="Email"
            onChange={(e) => setEmail(e.target.value)}
          />
        </div>
        <div>
          <input
            type="password"
            placeholder="Password"
            onChange={(e) => setPassword(e.target.value)}
          />
        </div>
        <div>
          <button type="submit">Sign In</button>
        </div>
      </form>
    </div>
  );
};

export default Login;

The logic is exactly the same as in the Register component. We either show an error message, or we redirect a user to the homepage after successful login.

Getting a User

Now comes the most interesting part. We didn't save our token after login in either a cookie or local storage. This means that after a page reload, we should not be logged in. But this is not true because Firebase saves all this information automatically.

indexdb

If we open IndexedDB, you can see that we have values from Firebase inside it. So now the only thing that we need to do is to call the correct function from Firebase to get our user again after page reload. But the main problem is that we want to share this user across the whole application, so we need to use React.Context for this.

// src/app/context/auth.js
import { onAuthStateChanged } from "firebase/auth";
import { createContext, useContext, useEffect, useState } from "react";
import { auth } from "../firebase";

export const AuthContext = createContext({
  currentUser: null,
});

export const useAuth = () => {
  return useContext(AuthContext);
};

export const AuthProvider = ({ children }) => {
  const [currentUser, setCurrentUser] = useState(null);

  useEffect(() => {
    onAuthStateChanged(auth, (user) => {
      if (user) {
        setCurrentUser({
          email: user.email,
          username: user.displayName,
        });
      } else {
        setCurrentUser(null);
      }
    });
  }, []);
  return (
    <AuthContext.Provider value={{ currentUser }}>
      {children}
    </AuthContext.Provider>
  );
};

We created a new context which stores currentUser inside. We use the onAuthStateChanged function from Firebase, which will notify us when the user data is changed, either after sign-in or sign-out.

Now, we need to wrap the whole application with this provider.

// src/app/layout.js
import { AuthProvider } from "./context/auth";

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <AuthProvider>
          <Header />
          {children}
        </AuthProvider>
      </body>
    </html>
  );
}

This will allow us to access currentUser from any component of our application.

user data

Now, after a page reload, we get user information from the onAuthStateChanged function. So Firebase used IndexedDB to authenticate a user after page reload.

We can use our Context in the Header component to get user information and render it.

// src/app/header.js
import Link from "next/link";
import { useAuth } from "./context/auth";
import { Fragment } from "react";
import { logout } from "./firebase";

const Header = () => {
  const { currentUser } = useAuth();
  console.log(currentUser);
  return (
    <div>
      <div>
        <Link href="/">Home</Link>
        {currentUser === null && (
          <Fragment>
            <Link href="/login">Login</Link>
            <Link href="/register">Register</Link>
          </Fragment>
        )}
      </div>
      <div>
        {currentUser && (
          <div>
            {currentUser.username} <span>Logout</span>
          </div>
        )}
      </div>
    </div>
  );
};
export default Header;

We are reading currentUser from our useAuth hook and using this information to render the correct links.

links

Now we can see the information of the current user and not the links for unauthorized users.

Logout

The last thing that we are missing is logout. But it's really easy to implement. First, we need to create a function in our firebase.js.

export const logout = () => {
  return auth.signOut();
};

And use it for our logout button.

import { logout } from "./firebase";
...
{currentUser.username} <span onClick={() => logout()}>Logout</span>

Now, when we click logout, our user information will be removed from IndexedDB, and we are no longer logged in.

Want to conquer your next JavaScript interview? Download my FREE PDF - Pass Your JS Interview with Confidence and start preparing for success today!

📚 Source code of what we've done
Did you like my post? Share it with friends!
Don't miss a thing!
Follow me on Youtube, Twitter or Instagram.
Oleksandr Kocherhin
Oleksandr Kocherhin is a full-stack developer with a passion for learning and sharing knowledge on Monsterlessons Academy and on his YouTube channel. With around 15 years of programming experience and nearly 9 years of teaching, he has a deep understanding of both disciplines. He believes in learning by doing, a philosophy that is reflected in every course he teaches. He loves exploring new web and mobile technologies, and his courses are designed to give students an edge in the fast-moving tech industry.