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.
Let's name our project next
, hit continue, and create the project.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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