The Ultimate Authentication Guide with Next.js and Firebase

The ultimate guide to adding Firebase authentication to any Next.js and React applications

Authentication is one of the most compelling reasons for using Firebase for your SaaS.

Firebase Authentication allows us to roll out a complete, secure, and functional authentication system for your single or multi-tenant SaaS applications.

Why do many companies choose to use Firebase Authentication?

  • it's a secure and battle-tested authentication system backed by Google
  • it's a straightforward API
  • it allows authenticating users using third-party effortlessly authentication providers, such as Facebook, Google, Twitter, GitHub, and many more by using a single API interface
  • it's extendable using custom tokens: for example, you could easily implement your providers, such as Instagram, MetaMask, or others
  • the newest Auth library, released with Firebase version 9, has recently been shrunk to a much more acceptable level

In this guide, you can learn how to build and deploy a secure authentication system for any SaaS application.

Then, inspired by MakerKit's codebase, we're going to build a hyper minimal Next.js application that can authenticate users using multiple providers, such as Email + Password, Facebook, Google, Twitter (or any other provider supported by Firebase).

It's the ultimate guide to authentication with Next.js + Firebase: it explains in extreme detail:

  • how to set up your Next.js application
  • create a Firebase project
  • sign in and up with the various methods offered by Firebase
  • signing users out
  • introducing Server-Side Rendering (SSR) and which strategies we need to employ to make it possible

NB: while not 100% its code, the article below gets very close to MakerKit's implementation of its authentication boilerplate for Firebase and Next.js. We are repurposing the code so that you can take a piece of MakerKit without purchasing a license.

If you enjoy reading this article, you can sign up for our Newsletter: you can receive more content like this post.

We split this guide into multiple smaller articles for each topic.

Setting up Firebase and your Next.js App

If you haven't created a Firebase project yet, we suggest you look at our documentation to setting up a Firebase project.

If you have followed the guide above, at this point, you already have access to your Firebase project. We can now create a Next.js application and install the required packages using the Firebase SDK and the Firebase Emulators.

This guide means to be as complete as possible: it shows you the complete flow from creating an application to having a fully functioning application.

If you already have a Firebase Project and application, you do not have to go through each step; feel free to skim towards the code.

Creating a new Next.js application

If you have already created a Next.js application, skip this section. Otherwise, please sread on.

To create a Next app using our CLI, we can use create-next-app: a package by Vercel that can kickstart a minimal template ready to be used.

We are voluntarily using a very minimal template to see, step-by-step, how to add Firebase authentication to any codebase.

Let's buckle up! We use the terminal and run the following command (with either npm or yarn):

npx create-next-app@latest --ts
# or
yarn create next-app --ts

This command should prompt you for the application name; of course, choose what seems the most suitable to you.

Installing dependencies with npm

Let's install some dependencies that are handy to work with React and Firebase.

First, we want to install firebase globally using:

npm i -g firebase-tools

Now, let's add the client-side dependencies:

npm i -d reactfire firebase

NB: for this blog post, we're going to use Typescript.

Running the Next.js development server

Now that we have created the Next.js application, we can run the development server with the following command:

npm run dev

If everything is good, you should be able to navigate to the URL http://localhost:3000 and see the app running in your browser.

Tip: if you're using VSCode or WebStorm, you can run the NPM commands from the left-hand sidebar.

Why use the Firebase Emulators?

The Firebase Emulators are an essential development tool that allows us to emulate most (if not all) of the Firebase Platform locally.

The emulators are an incredible tool. They allow us to develop our applications without the need for making external calls (thus keeping our free quota intact), without the risk of excessive bills from bugs while developing, and obviously with much quicker developer feedback.

Furthermore, the Firebase Emulators come with a UI console we can interact with, similar to the actual Firebase Console.

Do you need a hand setting up the Firebase Emulators locally? Don't worry. We have written a guide for setting up the Firebase Emulators with Next.js, just like the MakerKit SaaS starter provides out of the box for you.

Getting Started with Firebase Auth

In this section, we set up your Next.js application with Firebase Auth.

Configuring your application

After you've created your Firebase project, navigate to the Firebase Console.

At this point, we need to copy the configuration of your Firebase project and add it to your application's code so that we can connect with your Firebase Project.

Adding the Environment Variables to our Next.js project

We can add the configuration to our environment variables file named .env.

Using an environment file can make it possible to swap the values depending on which environment we're running. For example, we can create two environments: staging and production. For the sake of this article, we create only one single environment file, but you should be aware that this is a possibility.

First of all, let's populate the environment variables with the configuration that you can find in your Firebase Console:

.env.development
# FIREBASE PUBLIC VARIABLES
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_APP_ID=
NEXT_PUBLIC_FIREBASE_EMULATOR_HOST=localhost
NEXT_PUBLIC_EMULATOR=true
NEXT_PUBLIC_FIRESTORE_EMULATOR_PORT=8080
NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_PORT=9099
NEXT_PUBLIC_FIREBASE_STORAGE_EMULATOR_PORT=9199

As you can see, we pre-populated some of the variables with sane defaults. Furthermore, we are going to use the Firebase Emulators by default.

If we configured everything well, you could now run the Firebase Emulators. Run this command in your terminal to run the Firebase emulators:

firebase emulators:start

Are you having any issues? Please check out the MakerKit documentation for setting up a Firebase project; it is a step-by-step guide for configuring your Firebase setup.

Creating the configuration file

Afterward, we want to use a single configuration file that we can easily import.

Why use a configuration file? Can we not easily access the variables using process.env.MY_VARIABLE?

Good question! This is not required, but I would argue it's a good practice for a couple of reasons:

  • it allows us to define the configuration in one single place
  • it's better typed because process.env is any, which makes it easy to misspell variables without having Typescript help us

Let's create a file named configuration.ts in the root project, and export an object containing our environment variables:

configuration.ts
const configuration = {
firebase: {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
},
emulatorHost: process.env.NEXT_PUBLIC_EMULATOR_HOST,
emulator: process.env.NEXT_PUBLIC_EMULATOR === 'true',
};
export default configuration;

We can simplify importing this file by setting up a custom path using our tsconfig.json file:

tsconfig.json
{
"compilerOptions": {
"paths": {
"~/configuration": [
"./configuration.ts"
]
}
}
}

By adding the paths object, we can now import the configuration from any file in the project in the following way:

import configuration from `~/configuration.ts`;

It's time to get into the building phase of this post. What have we done so far?

  • we created a Firebase project and signed in
  • we built a minimal Next.js application using create-next-app
  • we copied the Firebase configuration into our application's configuration file

Perfect! Now that the setup is complete, we can build a fully functioning authentication flow for our application.

Adding the Firebase SDK to a Next.js App

We start by adding the Firebase SDK to our Next.js application.

We can leverage the components provided by reactfire (the official Firebase library for React) to initialize Firebase on each page.

Let's extend _app.tsx:

  • we initialize the Firebase App
  • we initialize the Firebase Auth SDK
  • we wrap every component of the application with the Firebase SDK
_app.tsx
import { AppProps } from "next/app";
import { initializeApp } from 'firebase/app';
import {
initializeAuth,
indexedDBLocalPersistence,
connectAuthEmulator,
inMemoryPersistence,
} from 'firebase/auth';
import {
FirebaseAppProvider,
AuthProvider
} from 'reactfire';
import configuration from '~/configuration';
import { isBrowser } from "../lib/generic/isBrowser";
function App(props: AppProps) {
const { Component, pageProps } = props;
// we initialize the firebase app
// using the configuration that we defined above
const app = initializeApp(configuration.firebase);
// make sure we're not using IndexedDB when SSR
// as it is only supported on browser environments
const persistence = isBrowser()
? indexedDBLocalPersistence
: inMemoryPersistence;
const auth = initializeAuth(app, { persistence });
// prevent emulator from being
// called multiple times on page navigations
if (
configuration.emulator &&
!("emulator" in auth.config)
) {
// we can get the host by
// combining the local emulator host with the Auth port
const host = getAuthEmulatorHost();
connectAuthEmulator(sdk, host);
}
return (
<FirebaseAppProvider firebaseApp={app}>
<AuthProvider sdk={auth}>
<Component {...pageProps} />
</AuthProvider>
</FirebaseAppProvider>
);
}
export default App;

Creating Pages with Next.js

Before we start, I want to give you a quick overview of what we will do because it may require prior knowledge of Next.js. If this is your first time using Next.js, please read on; otherwise, feel free to skip to the following paragraphs.

As you may already know, Next.js's router is file-system-based. That means it follows the structure of our folders to map a page with its URL.

We place all the Next.js routes within the folder named pages: all the Typescript files within this folder become public-facing routes. The only difference is with the api folder, which we reserve for the serverless API functions.

Assuming we create the following folder structure:

- pages
- auth
- sign-up.tsx

Next.js generates the page rendered by sign-up.tsx at /auth/sign-up.

Adding Authentication to a Next.js App

Signing users up with Email and Password

We start with writing a sign-up page that allows users to sign up using two methods:

  • Email/Password Authentication
  • OAuth authentication for various third-party providers

Using a custom Hook to Sign users up with the Firebase SDK

Before creating the page, let's create the business logic that helps us sign users up. For this, we use a custom hook we call useSignUpWithEmailAndPassword.

We use another custom hook called useRequestState: you can find the complete implementation in the repository at the bottom of this post. All you need to know, for now, is that it is a simple hook that helps us manage the state of a request, similar to a state machine.

Ok then, let's create our custom hook to sign users up using the Firebase SDK.

use-signup-with-email-password.tsx
import { useAuth } from "reactfire";
import { FirebaseError } from "firebase/app";
import {
createUserWithEmailAndPassword,
UserCredential
} from "firebase/auth";
import { useRequestState } from "./useRequestState";
export function useSignUpWithEmailAndPassword() {
const auth = useAuth();
const {
state,
setLoading,
setData,
setError
} = useRequestState<
UserCredential,
FirebaseError
>();
const signUp = useCallback(async (email: string, password: string) => {
setLoading(true);
try {
const credential = await createUserWithEmailAndPassword(
auth,
email,
password
);
setData(credential);
} catch (error) {
setError(error as FirebaseError);
}
}, [auth, setData, setError, setLoading]);
return [signUp, state] as [
typeof signUp,
typeof state
];
}

The hook we created returns an array with two elements:

  • the first element is the signUp function which the consumer calls upon submission
  • the second element is the current state of the hook, which changes accordingly to the execution of the function in the first array element

We create a reusable component for the Email/Password sign-up form named EmailPasswordSignUpForm:

  • the EmailPasswordSignUpForm component is responsible for signing the user up; finally, it calls the onSignup callback when the user successfully signs up
  • the parent component calls the onSignup callback to redirect the user away from the page
EmailPasswordSignUpForm.tsx
import { FormEvent, useCallback, useEffect } from "react";
import { useSignUpWithEmailAndPassword } from "../lib/hooks/useSignUpWithEmailAndPassword";
function EmailPasswordSignUpForm(
props: React.PropsWithChildren<{
onSignup: () => void;
}>
) {
const [signUp, state] =
useSignUpWithEmailAndPassword();
const loading = state.loading;
const error = state.error;
useEffect(() => {
if (state.success) {
props.onSignup();
}
}, [props, state.success]);
const onSubmit = useCallback(
async (
event: FormEvent<HTMLFormElement>
) => {
event.preventDefault();
if (loading) {
return;
}
const data = new FormData(event.currentTarget);
const email = data.get(`email`) as string;
const password = data.get(`password`) as string;
// sign user up
return signUp(email, password);
},
[loading, props, signUp]
);
return (
<form className={"w-full"} onSubmit={onSubmit}>
<div className={"flex-col space-y-6"}>
<input
required
placeholder="Your Email"
name="email"
type="email"
className="TextField"
/>
<input
required
placeholder="Your Password"
name="password"
type="password"
className="TextField"
/>
{
error ? <span className="text-red-500">{error.message}</span> : null
}
<button
disabled={loading}
className="Button w-full"
>
Sign Up
</button>
</div>
</form>
);
}
export default EmailPasswordSignUpForm;

Ok, there is a lot of code above. So let's digest it slowly:

  • we call the onSubmit callback when the user submits the sign-up form
  • then, we can read the form using new FormData(form) and pass the email and password data to the signUp function, which we get from the useSignUpWithEmailAndPassword hook
  • when the promise returned by the signUp hook completes, we use the Next.js router to redirect the user to the restricted page

Create a new Typescript file sign-up.tsx and place it in the folder at the following path: /pages/auth/:

sign-up/page.tsx
import { useCallback } from "react";
import { useRouter } from "next/router";
const SignUp = () => {
const router = useRouter();
const onSignup = useCallback(() => {
router.push("/dashboard");
}, [router]);
return (
<div className="AuthContainer">
<h1 className="Index">Sign Up</h1>
<EmailPasswordSignUpForm onSignup={onSignup} />
</div>
);
};
export default SignUp;

Signing users up with OAuth Providers

We can finally complete the sign-up process by authenticating with oAuth providers.

Implementing authenticating for each third-party provider can be a lengthy process; fortunately, Firebase makes it much more manageable.

We now create a new hook named useSignInWithProvider, so we can abstract the process of calling the Firebase SDK and make it hooky:

use-sign-in-with-provider.ts
import { useAuth } from 'reactfire';
import { FirebaseError } from 'firebase/app';
import {
AuthProvider,
signInWithPopup,
browserPopupRedirectResolver,
UserCredential,
} from 'firebase/auth';
import { useRequestState } from './useRequestState';
export function useSignInWithProvider() {
const auth = useAuth();
const {
state,
setLoading,
setData,
setError
} = useRequestState<
UserCredential,
FirebaseError
>();
const signInWithProvider = useCallback(async (provider: AuthProvider) => {
setLoading(true);
try {
const credential = await signInWithPopup(
auth,
provider,
browserPopupRedirectResolver
);
setData(credential);
} catch (error) {
setError(error as FirebaseError);
}
}, [auth, setData, setError, setLoading]);
return [signInWithProvider, state] as [
typeof signInWithProvider,
typeof state
];
}

The custom hook above returns an array with two elements, as defined previously. Now we can extend the Sign Up page to include the oAuth provider sign-up buttons.

For simplicity reasons, we're only going to use Google Auth. However, you can easily extend the button below to support any support provider.

AuthProviderButton.tsx
import { GoogleAuthProvider } from 'firebase/auth';
import { useSignInWithProvider } from '~/core/firebase/hooks';
// you should add this to the render
// function of the sign-up.tsx component
const AuthProviderButton = () => {
return (
<button
className="rounded-lg p-2 font-bold bg-red-400 text-white"
onClick={() => {
signInWithProvider(
new GoogleAuthProvider()
);
}}>
Login with Google
</button>
)
};
useEffect(() => {
if (signInWithProviderState.success) {
onSignup();
}
}, [signInWithProviderState.success, onSignup]);

Let's explain what we have added:

  • The AuthProviderButton is a button that uses the Firebase SDK when clicked by invoking a dialog containing the oAuth provider that the user selected
  • When the login is successful, we can use a side-effect with useEffect to call the onSignup function, which redirects the user away

Signing users into your Next.js App

Thanks to Firebase's simple API, signing users in is quite similar to the sign-up: for signing up users, we used createUserWithEmailAndPassword; we are now going to use signInWithEmailAndPassword.

We copy the useSignUpWithEmailAndPassword hook, rename it to useSignInWithEmailAndPassword, and replace the function.

Instead, the oAuth providers sign-up don't need any change: Firebase is smart enough to determine if the user signing in/up with a third-party provider has already created an account, so we also leave it as is.

Using SSR with Authentication

You may not care about this part, so feel free to skip it. But it may be helpful for you to know that we can use SSR with Firebase Authentication.

If you are interested in using SSR with your SaaS, let's address the elephant in the room. Why would you want to use SSR with your SaaS application? If you don't need SEO, why server-side render pages that Search Engines cannot access?

Besides SEO, there are some other compelling reasons:

  • more flexibility by handling redirects on the server-side: for example, if a user doesn't have access to a portion of your website, you can redirect the user before even rendering the page
  • avoid spinners hell: by providing your data along with the HTML, you can avoid chains of HTTP requests, typical for Single Page Applications (SPA) or Static Site Generated (SSG), which increases the perceived loading time of your app

If the reasons above sparked your interest, we recommend you to check out the complete guide to using SSR with a Firebase application using Next.js.

Protecting Pages for authenticated users

There are two ways for protecting your pages and allowing only authenticated users to access them:

  • listen to the token changes client-side, and force redirect the user when the token gets revoked
  • prevent the page from being accessed from the server-side using SSR

Truthfully, they both play an essential role, and the best way to protect your pages is to employ both strategies.

You may not be using Server-Side Rendering (SSR) for all your pages (for example, your blog or your site home page), but you may want to retain the sign-in state on every page.

At MakerKit, we think this is what a modern website should be like.

Signing users out

Signing users out can happen as a result of various reasons, such as:

  • when the user voluntarily decides to log out
  • when the browser's local storage gets erased
  • when the local token expires, and it does not get refreshed

In any case, we know that the user is no longer signed-in by using the Firebase SDK, which emits a callback with an undefined user object. Therefore, we need to create a listener that lasts for the entire length of the user session.

Implementing the user-initiated sign-out

We want to look at the most common situation first: when the user voluntarily signs out of the application.

Let's create a simple button which calls the Firebase SDK and calls the signOut method provided:

SignOutButton.tsx
const SignOutButton = () => {
const auth = useAuth();
const onSignOutRequested = useCallback(() => {
return signOut(auth);
}, [auth]);
return (
<button
className='Button'
onClick={onSignOutRequested}
>
Sign Out
</button>
);
};
export default SignOutButton;

When the user clicks on the button and signs out, the session gets erased from the browser's local storage, and the SDK lets us know by sending the client an event that the application needs to handle.

To complete the flow, we need to add the ability to redirect the user away from the protected page.

Protecting Routes from non-authenticated users

At this point, we need to declare a component that can wrap a protected route.

We want to distinguish between the routes that do require authenticated users and the public routes: if the component GuardedPage is wrapping the route, it gets automatically protected.

We can use a long-lived side-effect that can determine whether the user is currently signed in or not. To do so, we use the SDK method onAuthStateChanged: as the name suggests, it emits an event when the user's authentication state changes. If it's undefined, the user is no longer signed out.

Furthermore, if the user provided a parameter whenSignedOut, we can redirect the user to that page (typically the application's home page) using window.location.assign.

GuardedPage.tsx
const GuardedPage: React.FC<{
whenSignedOut?: string;
}> = ({ children, whenSignedOut }) => {
const auth = useAuth();
const { status } = useSigninCheck();
useEffect(() => {
// this should run once and only on success
if (status !== 'success') {
return;
}
// keep this running for the whole session
// unless the component was unmounted, for example, on log-outs
const listener =
auth.onAuthStateChanged((user) => {
const shouldLogOut =
!user && whenSignedOut;
// log user out if user is falsy
// and if the consumer provided a route to redirect the user
if (shouldLogOut) {
const path = window.location.pathname;
// we then redirect the user to the page
// specified in the props of the component
if (path !== whenSignedOut) {
window.location.assign(whenSignedOut);
}
}
});
// destroy listener on un-mounts
return () => listener();
}, [auth, status, whenSignedOut]);
return <>{children}</>;
};
export default GuardedPage;

The GuardedPage component can be used in the following way:

protected.tsx
const ProtectedPage = () => {
return (
<GuardedPage whenSignedOut='/auth/sign-in'>
{
// your page's content goes here
}
</GuardedPage>
);
};

Below you can see the final result of what we've done so far:

Final Words

We have arrived at the end of this long post. So, to summarize, what have we learned?

  • how to set up a new Firebase project with Next.js
  • how to leverage the Firebase emulators to supercharge our local development experience with Firebase
  • how to sign users in and up with both Email/Password and oAuth third party providers
  • how to sign users out
  • why you should consider using SSR even if you don't need to benefit from SEO

We will update this article very soon with the following topics:

  • how to use SSR with Firebase projects
  • how to manage a password reset flow with Firebase

We hope this blog post can help you implement a solid authentication flow for your SaaS using Next.js and Firebase. Of course, the above is not everything MakerKit can provide for you, but it should be enough to get your SaaS started.

The full source code is available on Github. It's open-source: clone, extend and share as much as you wish!