Mastering Next.js Firebase Authentication: A Comprehensive Guide for Modern Web Development

  • by
  • 9 min read

In today's rapidly evolving digital landscape, creating secure and user-friendly web applications is paramount. As developers, we're constantly seeking robust solutions that can streamline our workflow while ensuring top-notch security for our users. This comprehensive guide will delve deep into the integration of Firebase Authentication with Next.js, leveraging the latest features and best practices to create a cutting-edge authentication system.

The Power of Next.js and Firebase: A Perfect Synergy

Next.js has emerged as a powerhouse in the world of web development, offering a suite of features that make it an ideal choice for building modern, performant applications. With its server-side rendering capabilities, static site generation, and automatic code splitting, Next.js provides developers with the tools they need to create lightning-fast web experiences.

When combined with Firebase's authentication services, we unlock a new level of potential. Firebase, Google's mobile and web application development platform, offers a robust, scalable, and easy-to-implement authentication system. By integrating these two technologies, we can create secure, production-ready applications that can handle authentication seamlessly across both server and client components.

Introducing next-firebase-auth-edge: A Game-Changing Library

In this tutorial, we'll be using the next-firebase-auth-edge library, a zero-bundle size solution that revolutionizes how we handle authentication in Next.js applications. This library allows for effortless integration of Firebase Authentication, providing a smooth experience for both developers and end-users.

The next-firebase-auth-edge library offers several key advantages:

  1. Zero bundle size impact, ensuring your application remains lean and fast.
  2. Seamless integration with Next.js's latest features, including the App Router.
  3. Support for both server and client-side authentication flows.
  4. Easy-to-implement middleware for protecting routes.

By leveraging this library, we can create a robust authentication system that adheres to best practices and takes full advantage of Next.js's powerful features.

Setting Up Your Development Environment

Before we dive into the implementation, it's crucial to set up our development environment correctly. This process involves several steps, each of which is vital for ensuring a smooth development experience.

First, ensure you have Node.js installed on your system. The Long Term Support (LTS) version is recommended for stability. Node.js comes bundled with npm (Node Package Manager), which we'll use to manage our project dependencies.

Next, we'll create a new Next.js project using the following command:

npx create-next-app@latest my-auth-app
cd my-auth-app

During the project creation process, you'll be prompted with several options. For this tutorial, we recommend using TypeScript for its enhanced type safety, ESLint for code quality, and Tailwind CSS for rapid UI development. We'll also be using the new App Router, which offers improved routing capabilities in Next.js applications.

Once your project is set up, it's time to configure Firebase. Head over to the Firebase Console and create a new project. Enable Firebase Authentication in your project settings and add a new web app. Make sure to copy the Firebase configuration details, as we'll need them later.

With our project structure in place and Firebase set up, we can now install the necessary dependencies:

npm install next-firebase-auth-edge firebase

These packages will provide us with the tools we need to integrate Firebase Authentication into our Next.js application seamlessly.

Configuring Firebase Authentication: The Foundation of Security

Proper configuration is key to ensuring a secure and efficient authentication system. We'll start by setting up our environment variables, which will store sensitive information such as API keys and private keys.

Create a .env.local file in your project root and add the following variables:

FIREBASE_ADMIN_CLIENT_EMAIL=your-client-email
FIREBASE_ADMIN_PRIVATE_KEY=your-private-key

AUTH_COOKIE_NAME=AuthToken
AUTH_COOKIE_SIGNATURE_KEY_CURRENT=your-secret-key-1
AUTH_COOKIE_SIGNATURE_KEY_PREVIOUS=your-secret-key-2

USE_SECURE_COOKIES=false

NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-project-id
NEXT_PUBLIC_FIREBASE_API_KEY=your-api-key
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-auth-domain
NEXT_PUBLIC_FIREBASE_DATABASE_URL=your-database-url
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your-sender-id

Remember to replace the placeholders with your actual Firebase configuration values. It's crucial to keep these environment variables secure and never commit them to version control.

Next, we'll create a config.ts file to manage our Firebase configuration:

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
};

This configuration file separates server-side and client-side configurations, ensuring that sensitive information is only accessible where needed.

Implementing Authentication Middleware: Securing Your Routes

One of the key features of next-firebase-auth-edge is its powerful middleware capabilities. By implementing authentication middleware, we can easily protect our routes and ensure that only authenticated users can access certain parts of our application.

Create a middleware.ts file in your project root:

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",
  ],
};

This middleware handles authentication for all routes except static files and API routes (excluding login and logout). It also implements redirection logic, ensuring that authenticated users are directed to the home page when trying to access public routes, and unauthenticated users are redirected to the login page when attempting to access protected routes.

Creating Authentication Pages: The User Interface

With our backend configuration in place, it's time to create the user interface for our authentication system. We'll start by implementing registration and login pages.

Registration Page

Create a file 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">
            Create an account
          </h1>
          <form onSubmit={handleSubmit} className="space-y-4 md:space-y-6" action="#">
            {/* Email input */}
            {/* Password input */}
            {/* Confirm password input */}
            {/* Error message */}
            <button type="submit" className="w-full text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-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-blue-600 hover:underline dark:text-blue-500">Login here</Link>
            </p>
          </form>
        </div>
      </div>
    </main>
  );
}

This registration page allows users to create a new account using their email and password. It includes basic form validation and error handling.

Login Page

Create a file 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">
            Sign in to your account
          </h1>
          <form onSubmit={handleSubmit} className="space-y-4 md:space-y-6" action="#">
            {/* Email input */}
            {/* Password input */}
            {/* Error message */}
            <button type="submit" className="w-full text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
              Sign in
            </button>
            <p className="text-sm font-light text-gray-500 dark:text-gray-400">
              Don't have an account yet? <Link href="/register" className="font-medium text-blue-600 hover:underline dark:text-blue-500">Sign up</Link>
            </p>
          </form>
        </div>
      </div>
    </main>
  );
}

The login page allows existing users to sign in to their accounts. Upon successful authentication, it sets a session cookie and redirects the user to the home page.

Implementing Protected Routes: Securing Your Application

With our authentication system in place, we can now implement protected routes that are only accessible to authenticated users. We'll start by updating our home page to require authentication.

Update app/page.tsx:

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} />;
}

This server component checks for the presence of valid authentication tokens. If no valid token is found, it triggers a 404 Not Found response.

Next, create 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">Welcome to Your Secure Dashboard</h1>
      <p className="mb-8">
        Hello, <strong>{email}</strong>! You're now in a protected area.
      </p>
      <button
        onClick={handleLogout}
        className="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.

Did you like this post?

Click on a star to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.