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# oryarn 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:
# FIREBASE PUBLIC VARIABLESNEXT_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=localhostNEXT_PUBLIC_EMULATOR=trueNEXT_PUBLIC_FIRESTORE_EMULATOR_PORT=8080NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_PORT=9099NEXT_PUBLIC_FIREBASE_STORAGE_EMULATOR_PORT=9199
As you can see, we use the prefix NEXT_PUBLIC_
to define these variables. This prefix makes the variables public, which means they get bundled in our client-side Javascript.
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.
Please fill in the variables above with your project's ones before continuing.
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
isany
, 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:
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:
{ "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
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.
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 theonSignup
callback when the user successfully signs up - the parent component calls the
onSignup
callback to redirect the user away from the page
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 thesignUp
function, which we get from theuseSignUpWithEmailAndPassword
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/
:
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:
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.
import { GoogleAuthProvider } from 'firebase/auth';import { useSignInWithProvider } from '~/core/firebase/hooks';// you should add this to the render// function of the sign-up.tsx componentconst 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 theoAuth
provider that the user selected - When the login is successful, we can use a side-effect with
useEffect
to call theonSignup
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:
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
.
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:
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!