Using Firebase Authentication with the Latest Next.js Features

Written by awinogrodzki | Published 2024/04/04
Tech Story Tags: nextjs | firebase | firebase-auth | middleware | next-firebase-auth-edge | setting-up-firebase | authenticated-next.js-app | hackernoon-top-story

TLDRA comprehensive, step-by-step guide on integrating Firebase Authentication with Next.js using the zero-bundle size `next-firebase-auth-edge` library. It includes steps for user registration, login, and logout functionalities, along with redirect logic for a seamless user experience. In this guide, you're going to learn how to integrate Firebase Authentication with the latest Next.js features, such as App Router, Middleware, and Server Components. It concludes with instructions on deploying the app to Vercel, showcasing the library's ease of use and future-proof design for developers looking to enhance their Next.js applications with Firebase Authentication.via the TL;DR App

Introduction to next-firebase-auth-edge

You likely found this article while searching for ways to add Firebase Authentication to your existing or new Next.js application. You aim to make a smart, unbiased, and future-oriented decision that will maximize the chances of your app being successful. As the creator of next-firebase-auth-edge, I must admit that providing an entirely unbiased opinion is not my forte, but at least I’ll try to justify the approach I took when designing the library. Hopefully, by the end of this guide, you may find the approach both simple and viable for the long term.

How it started

I’ll save you long introductions. Let me just say that the idea for the library was inspired by a situation possibly akin to yours. It was the time when Next.js released a canary version of App Router. I was working on app that heavily relied on rewrites and internal redirects. For that, we were using custom Node.js express server rendering Next.js app.

We were really excited for App Router and Server Components, yet aware that it wouldn’t be compatible with our custom server. Middleware seemed like powerful feature we could leverage to eliminate the need for a custom Express server, opting instead to rely solely on Next.js's built-in features to redirect and rewrite users to different pages dynamically.

That time, we were using next-firebase-auth. We really liked the library, but it spread out our authentication logic through next.config.js , pages/_app.tsx , pages/api/login.ts, pages/api/logout.ts files, which were going to be considered legacy soon enough. Also, the library wasn’t compatible with the middleware, preventing us from rewriting URLs or redirecting users based on their context.

So, I began my search, but to my surprise, I found no library that supported Firebase Authentication within middleware. – Why could that be? It’s impossible! As a software engineer with more than 11 years of commercial experience in Node.js and React, I was gearing up to tackle this conundrum.

So, I started. And the answer became obvious. Middleware is running inside Edge Runtime. There is no firebase library compatible with Web Crypto APIs available inside Edge Runtime. I was doomed. I felt helpless. Is this the first time I will need to actually wait to get to play with the new and fancy APIs? – Nope. A watched pot never boils. I quickly stopped sobbing and began to reverse-engineer next-firebase-auth, firebase-admin, and several other JWT authentication libraries, adapting them to the Edge Runtime. I seized the opportunity to address all the issues I had encountered with previous authentication libraries, aiming to create the lightest, easiest-to-configure, and future-oriented authentication library.

About two weeks later, version 0.0.1 of next-firebase-auth-edge was born. It was a solid proof of concept, but you wouldn’t want to use version 0.0.1 . Trust me.

How its going

Nearly two years later, I'm thrilled to announce that after 372 commits, 110 resolved issues, and a boatload of invaluable feedback from awesome developers worldwide, the library has reached a stage where my other self nodes to me in approval.

In this guide, I will use version 1.4.1 of next-firebase-auth-edge to create authenticated Next.js app from scratch. We will go through each step in detail, starting with the creation of a new Firebase project and Next.js app, followed by the integration with next-firebase-auth-edge and firebase/auth libraries. At the end of this tutorial, we will deploy the app to Vercel to confirm that everything is working both locally and in production-ready environment.

Setting up Firebase

This part assumes you haven’t yet setup Firebase Authentication. Feel free to skip to the next part if otherwise.

Let’s head to Firebase Console and create a project

After project is created, let’s enable Firebase Authentication. Open the console and follow to Build > Authentication > Sign-in method and enable Email and password method. That’s the method we’re going to support in our app

After you enabled your first sign-in method, Firebase Authentication should be enabled for your project and you can retrieve your Web API Key in Project Settings

Copy the API key and keep it safe. Now, let’s open next tab – Cloud Messaging, and note down Sender ID. We’re going to need it later.

Last but not least, we need to generate service account credentials. Those will allow your app to gain full access to your Firebase services. Go to Project Settings > Service accounts and click Generate new private key. This will download a .json file with service account credentials. Save this file in a known location.

That’s it! We are ready to integrate Next.js app with Firebase Authentication

Creating Next.js app from scratch

This guide assumes you have Node.js and npm installed. Commands used in this tutorial were verified against latest LTS Node.js v20. You can verify node version by running node -v in terminal. You can also use tools like NVM to quickly switch between Node.js versions.

Setting up Next.js app with CLI

Open your favourite terminal, navigate to your projects folder and run

npx create-next-app@latest

To keep it simple, let’s use default configuration. This means we’ll be using TypeScript and tailwind

✔ What is your project named? … my-app
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … No
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … No

Let’s navigate to project’s root dir and make sure all dependencies are installed

cd my-app

npm install

To confirm everything works as expected, let’s start Next.js dev server with npm run dev command. When you open http://localhost:3000 , you should see Next.js welcome page, similar to this:

Preparing environment variables

Before we start integrating with Firebase, we need a secure way to store and read our Firebase configuration. Luckily, Next.js ships with built-in dotenv support.

Open your favourite code editor and navigate to project folder

Let’s create .env.local file in project’s root directory and fill it with following environment variables:

FIREBASE_ADMIN_CLIENT_EMAIL=...
FIREBASE_ADMIN_PRIVATE_KEY=...

AUTH_COOKIE_NAME=AuthToken
AUTH_COOKIE_SIGNATURE_KEY_CURRENT=secret1
AUTH_COOKIE_SIGNATURE_KEY_PREVIOUS=secret2

USE_SECURE_COOKIES=false

NEXT_PUBLIC_FIREBASE_PROJECT_ID=...
NEXT_PUBLIC_FIREBASE_API_KEY=AIza...
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=....firebaseapp.com
NEXT_PUBLIC_FIREBASE_DATABASE_URL=....firebaseio.com
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=...

Please note that variables prefixed with NEXT_PUBLIC_ will be available in client-side bundle. We’ll need those to setup Firebase Auth Client SDK

NEXT_PUBLIC_FIREBASE_PROJECT_ID , FIREBASE_ADMIN_CLIENT_EMAIL and FIREBASE_ADMIN_PRIVATE_KEY can be retrieved from .json file downloaded after generating service account credentials

AUTH_COOKIE_NAME will be the name of the cookie used to store user credentials

AUTH_COOKIE_SIGNATURE_KEY_CURRENT and AUTH_COOKIE_SIGNATURE_KEY_PREVIOUS are secrets we’re going to sign the credentials with

NEXT_PUBLIC_FIREBASE_API_KEY is Web API Key retrieved from Project Settings general page

NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN is your-project-id.firebaseapp.com

NEXT_PUBLIC_FIREBASE_DATABASE_URL is your-project-id.firebaseio.com

NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID can be obtained from Project Settings > Cloud Messaging page

USE_SECURE_COOKIES won’t be used for local development, but will come in handy when we’ll deploy our app to Vercel

Integrating with Firebase Authentication

Installing next-firebase-auth-edge and initial configuration

Add the library to project’s dependencies by running npm install next-firebase-auth-edge@^1.4.1

Let's create config.ts file to encapsulate our project configuration. It’s not required, but will make code examples more readable.

Don’t spend too much time pondering those values. We will explain them in more detail as we follow along.

export const serverConfig = {
  cookieName: process.env.AUTH_COOKIE_NAME!,
  cookieSignatureKeys: [process.env.AUTH_COOKIE_SIGNATURE_KEY_CURRENT!, process.env.AUTH_COOKIE_SIGNATURE_KEY_PREVIOUS!],
  cookieSerializeOptions: {
    path: "/",
    httpOnly: true,
    secure: process.env.USE_SECURE_COOKIES === "true",
    sameSite: "lax" as const,
    maxAge: 12 * 60 * 60 * 24,
  },
  serviceAccount: {
    projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
    clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL!,
    privateKey: process.env.FIREBASE_ADMIN_PRIVATE_KEY?.replace(/\\n/g, "\n")!,
  }
};

export const clientConfig = {
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
};

Adding Middleware

Create middleware.ts file in project’s root and paste the following

import { NextRequest } from "next/server";
import { authMiddleware } from "next-firebase-auth-edge";
import { clientConfig, serverConfig } from "./config";

export async function middleware(request: NextRequest) {
  return authMiddleware(request, {
    loginPath: "/api/login",
    logoutPath: "/api/logout",
    apiKey: clientConfig.apiKey,
    cookieName: serverConfig.cookieName,
    cookieSignatureKeys: serverConfig.cookieSignatureKeys,
    cookieSerializeOptions: serverConfig.cookieSerializeOptions,
    serviceAccount: serverConfig.serviceAccount,
  });
}

export const config = {
  matcher: [
    "/",
    "/((?!_next|api|.*\\.).*)",
    "/api/login",
    "/api/logout",
  ],
};

Believe it or not, but we’ve just integrated our App’s server with Firebase Authentication. Before we’re going to actually use it, let’s explain the configuration a bit:

loginPath will instruct authMiddleware to expose GET /api/login endpoint. When this endpoint is called with Authorization: Bearer ${idToken}* header, it responds with HTTP(S)-Only Set-Cookie header containing signed custom and refresh tokens

*idToken is retrieved with getIdToken function available in Firebase Client SDK. More on this later.

Similarly, logoutPath instructs the middleware to expose GET /api/logout, but it does not require any additional headers. When called, it removes authentication cookies from the browser.

apiKey is Web API Key. Middleware uses it to refresh custom token and reset authentication cookies after credentials are expired.

cookieName is the name of the cookie set and removed by /api/login and /api/logout endpoints

cookieSignatureKeys a list of secret keys user credentials are signed with. Credentials are always going to be signed with the first key in the list, thus you need to provide at least one value. You can provide multiple keys to perform a key rotation

cookieSerializeOptions are options passed to cookie when generating Set-Cookie header. See cookie README for more information

serviceAccount authorises the library to use your Firebase services.

The matcher instructs Next.js server to run Middleware against /api/login , /api/logout , / and any other path that isn’t a file or api call.

export const config = {
  matcher: [
    "/",
    "/((?!_next|api|.*\\.).*)",
    "/api/login",
    "/api/logout",
  ],
};

You might be wondering why are we not enabling middleware for all /api/* calls. We could, but it’s a good practice to handle unauthenticated calls within API route handler itself. This is a bit out of scope for this tutorial, but if you’re interested, let me know and I’ll prepare some examples!

As you can see, configuration is minimal and with clearly defined purpose. Now, let’s start calling our /api/login and /api/logout endpoints.

Creating a secure home page

To make things as simple as possible, let’s clear default Next.js home page and replace it with some personalised content

Open ./app/page.tsx and paste this:

import { getTokens } from "next-firebase-auth-edge";
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { clientConfig, serverConfig } from "../config";

export default async function Home() {
  const tokens = await getTokens(cookies(), {
    apiKey: clientConfig.apiKey,
    cookieName: serverConfig.cookieName,
    cookieSignatureKeys: serverConfig.cookieSignatureKeys,
    serviceAccount: serverConfig.serviceAccount,
  });

  if (!tokens) {
    notFound();
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-24">
      <h1 className="text-xl mb-4">Super secure home page</h1>
      <p>
        Only <strong>{tokens?.decodedToken.email}</strong> holds the magic key to this kingdom!
      </p>
    </main>
  );
}

Let’s break this down bit by bit.

getTokens function is designed to validate and extract user credentials from cookies

const tokens = await getTokens(cookies(), {
  apiKey: clientConfig.apiKey,
  cookieName: serverConfig.cookieName,
  cookieSignatureKeys: serverConfig.cookieSignatureKeys,
  serviceAccount: serverConfig.serviceAccount,
});

It resolves with null, if user is unauthenticated, or an object containing two properties:

token which is idToken string that you could use to authorise API requests to external backend services. This is a bit out of scope, but it’s worth to mention that the library enables distributed service architecture. The token is compatible and ready to use with all official Firebase libraries on all platforms.

decodedToken as name suggests, is decoded version of the token , which contains all information needed to identify the user, including e-mail address, profile picture and custom claims, which further enables us to restrict access based on roles and permissions.

After getting tokens , we use notFound function from next/navigation to make sure the page is only accessible to authenticated users

if (!tokens) {
  notFound();
}

Finally, we render some basic, personalised user content

<main className="flex min-h-screen flex-col items-center justify-center p-24">
  <h1 className="text-xl mb-4">Super secure home page</h1>
  <p>
    Only <strong>{tokens?.decodedToken.email}</strong> holds the magic key to this kingdom!"
  </p>
</main>

Let’s run it.

In case you’ve closed your dev server, just run npm run dev .

When you try to access http://localhost:3000/, you should see 404: This page could not be found.

Success! We've kept our secrets safe from prying eyes!

Installing firebase and initialising Firebase Client SDK

Run npm install firebase at project’s root directory

After client SDK is installed, create firebase.ts file in project root directory and paste the following

import { initializeApp } from 'firebase/app';
import { clientConfig } from './config';

export const app = initializeApp(clientConfig);

This will initialise Firebase Client SDK and expose app object for client components

Creating a registration page

What’s the point of having super secure home page, if nobody can view it? Let’s build a simple registration page to let people in to our app.

Let’s create a new, fancy page under ./app/register/page.tsx

"use client";

import { FormEvent, useState } from "react";
import Link from "next/link";
import { getAuth, createUserWithEmailAndPassword } from "firebase/auth";
import { app } from "../../firebase";
import { useRouter } from "next/navigation";

export default function Register() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [confirmation, setConfirmation] = useState("");
  const [error, setError] = useState("");
  const router = useRouter();

  async function handleSubmit(event: FormEvent) {
    event.preventDefault();

    setError("");

    if (password !== confirmation) {
      setError("Passwords don't match");
      return;
    }

    try {
      await createUserWithEmailAndPassword(getAuth(app), email, password);
      router.push("/login");
    } catch (e) {
      setError((e as Error).message);
    }
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-8">
      <div className="w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700">
        <div className="p-6 space-y-4 md:space-y-6 sm:p-8">
          <h1 className="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
            Pray tell, who be this gallant soul seeking entry to mine humble
            abode?
          </h1>
          <form
            onSubmit={handleSubmit}
            className="space-y-4 md:space-y-6"
            action="#"
          >
            <div>
              <label
                htmlFor="email"
                className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
              >
                Your email
              </label>
              <input
                type="email"
                name="email"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                id="email"
                className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
                placeholder="[email protected]"
                required
              />
            </div>
            <div>
              <label
                htmlFor="password"
                className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
              >
                Password
              </label>
              <input
                type="password"
                name="password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                id="password"
                placeholder="••••••••"
                className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
                required
              />
            </div>
            <div>
              <label
                htmlFor="confirm-password"
                className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
              >
                Confirm password
              </label>
              <input
                type="password"
                name="confirm-password"
                value={confirmation}
                onChange={(e) => setConfirmation(e.target.value)}
                id="confirm-password"
                placeholder="••••••••"
                className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
                required
              />
            </div>
            {error && (
              <div
                className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
                role="alert"
              >
                <span className="block sm:inline">{error}</span>
              </div>
            )}
            <button
              type="submit"
              className="w-full text-white bg-gray-600 hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-primary-800"
            >
              Create an account
            </button>
            <p className="text-sm font-light text-gray-500 dark:text-gray-400">
              Already have an account?{" "}
              <Link
                href="/login"
                className="font-medium text-gray-600 hover:underline dark:text-gray-500"
              >
                Login here
              </Link>
            </p>
          </form>
        </div>
      </div>
    </main>
  );
}

I know. It’s a lot of text, but bear with me.

We start of with "use client"; to indicate that registration page will be using client-side APIs

  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [confirmation, setConfirmation] = useState("");
  const [error, setError] = useState("");

Then, we define some variables and setters to hold our form state

const router = useRouter();

async function handleSubmit(event: FormEvent) {
  event.preventDefault();

  setError("");

  if (password !== confirmation) {
    setError("Passwords don't match");
    return;
  }

  try {
    await createUserWithEmailAndPassword(getAuth(app), email, password);
    router.push("/login");
  } catch (e) {
    setError((e as Error).message);
  }
}

Here, we define our form submission logic. First, we validate if password and confirmation are equal, otherwise we update the error state. If values are valid, we create user account with createUserWithEmailAndPassword from firebase/auth . If this step fails (eg. the e-mail is taken), we inform user by updating the error.

If all goes well, we redirect user to /login page. You’re probably confused right now, and you’re right to be so. /login page does not exist yet. We’re just preparing for what’s going to be next.

When you visit http://localhost:3000/register, the page should look roughly like this:

Creating a login page

Now, that users are able to register, let them prove their identity

Create a login page under ./app/login/page.tsx

"use client";

import { FormEvent, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { getAuth, signInWithEmailAndPassword } from "firebase/auth";
import { app } from "../../firebase";

export default function Login() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const router = useRouter();

  async function handleSubmit(event: FormEvent) {
    event.preventDefault();
    setError("");

    try {
      const credential = await signInWithEmailAndPassword(
        getAuth(app),
        email,
        password
      );
      const idToken = await credential.user.getIdToken();

      await fetch("/api/login", {
        headers: {
          Authorization: `Bearer ${idToken}`,
        },
      });

      router.push("/");
    } catch (e) {
      setError((e as Error).message);
    }
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-8">
      <div className="w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700">
        <div className="p-6 space-y-4 md:space-y-6 sm:p-8">
          <h1 className="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
            Speak thy secret word!
          </h1>
          <form
            onSubmit={handleSubmit}
            className="space-y-4 md:space-y-6"
            action="#"
          >
            <div>
              <label
                htmlFor="email"
                className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
              >
                Your email
              </label>
              <input
                type="email"
                name="email"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                id="email"
                className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
                placeholder="[email protected]"
                required
              />
            </div>
            <div>
              <label
                htmlFor="password"
                className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
              >
                Password
              </label>
              <input
                type="password"
                name="password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                id="password"
                placeholder="••••••••"
                className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
                required
              />
            </div>
            {error && (
              <div
                className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
                role="alert"
              >
                <span className="block sm:inline">{error}</span>
              </div>
            )}
            <button
              type="submit"
              className="w-full text-white bg-gray-600 hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-primary-800"
            >
              Enter
            </button>
            <p className="text-sm font-light text-gray-500 dark:text-gray-400">
              Don&apos;t have an account?{" "}
              <Link
                href="/register"
                className="font-medium text-gray-600 hover:underline dark:text-gray-500"
              >
                Register here
              </Link>
            </p>
          </form>
        </div>
      </div>
    </main>
  );
}

As you see, it’s pretty similar to registration page. Let’s focus on the crucial bit:

async function handleSubmit(event: FormEvent) {
    event.preventDefault();
    setError("");

    try {
      const credential = await signInWithEmailAndPassword(
        getAuth(app),
        email,
        password
      );
      const idToken = await credential.user.getIdToken();

      await fetch("/api/login", {
        headers: {
          Authorization: `Bearer ${idToken}`,
        },
      });

      router.push("/");
    } catch (e) {
      setError((e as Error).message);
    }
  }

It’s where all the magic happens. We use signInEmailAndPassword from firebase/auth to retrieve user’s idToken .

Then, we call /api/login endpoint exposed by the middleware. This endpoint updates our browser cookies with user credentials.

Finally, we redirect user to the home page by calling router.push("/");

Login page should look roughly as this

Let’s test it out!

Go to http://localhost:3000/register, enter some random e-mail address and password to create an account. Use those credentials in http://localhost:3000/login page. After you click Enter, you should be redirected to super secure home page

We’ve finally got to see our own, personal, ultra secure home page! But wait! How do we get out?

We need to add a logout button not to lock out ourselves from the world forever (or 12 days).

Before we start, we need to create a client component that will be able to sign us out using Firebase Client SDK.

Lets create a new file under ./app/HomePage.tsx

"use client";

import { useRouter } from "next/navigation";
import { getAuth, signOut } from "firebase/auth";
import { app } from "../firebase";

interface HomePageProps {
  email?: string;
}

export default function HomePage({ email }: HomePageProps) {
  const router = useRouter();

  async function handleLogout() {
    await signOut(getAuth(app));

    await fetch("/api/logout");

    router.push("/login");
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-24">
      <h1 className="text-xl mb-4">Super secure home page</h1>
      <p className="mb-8">
        Only <strong>{email}</strong> holds the magic key to this kingdom!
      </p>
      <button
        onClick={handleLogout}
        className="text-white bg-gray-600 hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-primary-800"
      >
        Logout
      </button>
    </main>
  );
}

As you might have noticed, this is slightly modified version of our ./app/page.tsx . We had to create a separate client component, because getTokens works only inside server components and API route handlers, while signOut and useRouter require to be run in client context. A bit complicated, I know, but it’s actually quite powerful. I’ll explain later.

Let’s focus on the logout process

const router = useRouter();

async function handleLogout() {
  await signOut(getAuth(app));

  await fetch("/api/logout");

  router.push("/login");
}

First, we sign out from Firebase Client SDK. Then, we call /api/logout endpoint exposed by the middleware. We finish by redirecting user to /login page.

Let’s update our server home page. Go to ./app/page.tsx and paste the following

import { getTokens } from "next-firebase-auth-edge";
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { clientConfig, serverConfig } from "../config";
import HomePage from "./HomePage";

export default async function Home() {
  const tokens = await getTokens(cookies(), {
    apiKey: clientConfig.apiKey,
    cookieName: serverConfig.cookieName,
    cookieSignatureKeys: serverConfig.cookieSignatureKeys,
    serviceAccount: serverConfig.serviceAccount,
  });

  if (!tokens) {
    notFound();
  }

  return <HomePage email={tokens?.decodedToken.email} />;
}

Now, our Home server component is responsible only for fetching user tokens and passing it down to HomePage client component. This is actually pretty common and useful pattern.

Let’s test this:

Voila! We can now login and logout from the application at our own will. That’s perfect!

Or is it?

When unauthenticated user tries to enter home page by opening http://localhost:3000/ we show 404: This page could not be found.

Also, authenticated users are still able to access http://localhost:3000/register and http://localhost:3000/login page without having to log out.

We can do better.

It seems we are in need to add some redirect logic. Let’s define some rules:

  • When authenticated user tries to access /register and /login pages, we should redirect them to /
  • When unauthenticated user tries to access / page, we should redirect them to /login

Middleware is one of the best ways to handle redirects in Next.js apps. Luckily, authMiddleware supports number of options and helper functions to handle wide range of redirect scenarios.

Let’s open middleware.ts file and paste this updated version

import { NextRequest, NextResponse } from "next/server";
import { authMiddleware, redirectToHome, redirectToLogin } from "next-firebase-auth-edge";
import { clientConfig, serverConfig } from "./config";

const PUBLIC_PATHS = ['/register', '/login'];

export async function middleware(request: NextRequest) {
  return authMiddleware(request, {
    loginPath: "/api/login",
    logoutPath: "/api/logout",
    apiKey: clientConfig.apiKey,
    cookieName: serverConfig.cookieName,
    cookieSignatureKeys: serverConfig.cookieSignatureKeys,
    cookieSerializeOptions: serverConfig.cookieSerializeOptions,
    serviceAccount: serverConfig.serviceAccount,
    handleValidToken: async ({token, decodedToken}, headers) => {
      if (PUBLIC_PATHS.includes(request.nextUrl.pathname)) {
        return redirectToHome(request);
      }

      return NextResponse.next({
        request: {
          headers
        }
      });
    },
    handleInvalidToken: async (reason) => {
      console.info('Missing or malformed credentials', {reason});

      return redirectToLogin(request, {
        path: '/login',
        publicPaths: PUBLIC_PATHS
      });
    },
    handleError: async (error) => {
      console.error('Unhandled authentication error', {error});
      
      return redirectToLogin(request, {
        path: '/login',
        publicPaths: PUBLIC_PATHS
      });
    }
  });
}

export const config = {
  matcher: [
    "/",
    "/((?!_next|api|.*\\.).*)",
    "/api/login",
    "/api/logout",
  ],
};

That should be it. We’ve implemented all redirect rules. Let’s break this down.

const PUBLIC_PATHS = ['/register', '/login'];

  handleValidToken: async ({token, decodedToken}, headers) => {
    if (PUBLIC_PATHS.includes(request.nextUrl.pathname)) {
      return redirectToHome(request);
    }

    return NextResponse.next({
      request: {
        headers
      }
    });
  },

handleValidToken is called when valid user credentials are attached to the request, ie. user is authenticated. It is called with tokens object as the first, and Modified Request Headers as the second argument. It should resolve with NextResponse .

redirectToHome from next-firebase-auth-edge is a helper function that returns an object that can be simplified to NextResponse.redirect(new URL(“/“))

By checking PUBLIC_PATHS.includes(request.nextUrl.pathname) , we validate if authenticated user tries to access /login or /register page, and redirect to home if that’s the case.

handleInvalidToken: async (reason) => {
  console.info('Missing or malformed credentials', {reason});

  return redirectToLogin(request, {
    path: '/login',
    publicPaths: PUBLIC_PATHS
  });
},

handleInvalidToken is called when something expected happens. One of this expected events is user seeing your app for the first time, from another device, or after credentials have expired.

Having known that handleInvalidToken is called for unauthenticated user, we can proceed with the second rule: When unauthenticated user tries to access / page, we should redirect them to /login

Because there is no other condition to meet, we just return the result of redirectToLogin which can be simplified to NextResponse.redirect(new URL(“/login”)) . It also makes sure user does not fall into redirect loop.

Lastly,

handleError: async (error) => {
 console.error('Unhandled authentication error', {error});
      
 return redirectToLogin(request, {
   path: '/login',
   publicPaths: PUBLIC_PATHS
 });
}

Contrary to handleInvalidToken , handleError is called when something unexpected* happens and possibly needs to be investigated. You can find a list of possible errors with their description in the documentation

In case of an error, we log this fact and safely redirect user to the login page

*handleError can be called with INVALID_ARGUMENT error after Google public keys are updated.

This is a form of key rotation and is expected. See this Github issue for more info

Now, that’s it. Finally.

Let’s log-out from our web app and open http://localhost:3000/. We should be redirected to /login page.

Let’s log in again, and try to enter http://localhost:3000/login. We should be redirected to / page.

Not only we provided seamless user experience. next-firebase-auth-edge is zero-bundle size library that’s working only in the App’s server and does not introduce additional client-side code. The resulting bundle is truly minimal. That is what I call perfect.

Our app is now fully integrated with Firebase Authentication both in Server and Client components. We are ready to unleash the full potential of Next.js!


The app’s source code can be found in next-firebase-auth-edge/examples/next-typescript-minimal

Epilogue

In this guide, we’ve went through integrating new Next.js app with Firebase Authentication.

Although quite extensive, the article omitted some important parts of the authentication flow, such as password reset form or sign in methods other than email and password.

If you’re interested in the library, you can preview full-fledged next-firebase-auth-edge starter demo page.

It features Firestore integration, Server Actions, App-Check support and more

The library provides a dedicated documentation page with tons of examples

If you liked the article, I would appreciate starring next-firebase-auth-edge repository. Cheers! 🎉

Bonus – Deploying app to Vercel

This bonus guide will teach you how to deploy your Next.js app to Vercel

Creating git repository

To be able to deploy to Vercel, you will need to create a repository for your new app.

Head to https://github.com/ and create a new repository.

create-next-app already initiated a local git repository for us, so you just need to follow to your project’s root folder and run:

git add --all
git commit -m "first commit"
git branch -M main
git remote add origin [email protected]:path-to-your-new-github-repository.git
git push -u origin main

Adding new Vercel project

Go to https://vercel.com/ and sign in with your Github account

After you’ve logged in, go to Vercel’s Overview page and click Add New > Project

Click Import next to the Github repository we’ve just created. Don’t deploy yet.

Before we deploy, we need to provide project configuration. Let’s add some environment variables:

Remember to set USE_SECURE_COOKIES to true , as Vercel uses HTTPS by default

Now, we are ready to click Deploy

Wait a minute or two, and you should be able to access your app with url similar to this: https://next-typescript-minimal-xi.vercel.app/

Done. I bet you didn’t expect it to be that easy.


If you liked the guide, I would appreciate starring next-firebase-auth-edge repository.

You can also let me know your feedback in the comments. Cheers! 🎉


Written by awinogrodzki | Software engineer with more than 14 years of experience in backend and frontend development. Based in Wrocław, Poland
Published by HackerNoon on 2024/04/04