avatarMatija Žiberna

Summary

The provided content outlines a comprehensive guide on integrating Firebase Authentication with a Next.js 13 application using server components, including setting up Firebase, implementing authentication API endpoints, securing routes with middleware, and handling user login and logout.

Abstract

The guide begins by detailing the setup of Firebase Authentication within a Next.js 13 application, emphasizing the use of server components for efficient server-side rendering. It covers the necessary steps to configure both the frontend and backend using Firebase's SDKs and Firebase Admin SDK, respectively. The article explains how to manage user sign-in, generate and validate session cookies, and secure protected routes using middleware. It also provides instructions for creating a login page with Google authentication and fetching user details upon successful authentication. Additionally, the guide offers insights into implementing a logout mechanism, ensuring a complete authentication flow. The author aims to help developers save time by providing a clear and structured approach to integrating Firebase Authentication in a Next.js application.

Opinions

  • The author emphasizes the efficiency of using Firebase for authentication in web applications.
  • The use of Next.js 13's server components is highlighted as a key feature for optimizing the application's performance.
  • The guide is presented as a time-saving resource, suggesting that the process of integrating Firebase Authentication with Next.js can be complex and time-consuming without proper guidance.
  • The author provides a personal touch by concluding with a thank you note and encouraging readers to engage with the content and follow on social media platforms.
  • The guide is inspired by existing resources, indicating a community-driven approach to knowledge sharing and improvement.
  • The author advocates for the importance of secure authentication processes, particularly in the context of protecting user data and ensuring valid user sessions.

Setting Up Firebase Authentication with Next.js 13 App Router Using Server Components

UPDATED ON 16th November 2023

Firebase is an efficient platform for adding authentication to web applications, while Next.js 13.5, with its server components, allows developers to write React components rendered on the server.

What is this guide about

  1. Logging in a user on the front end by utilizing the Firebase API.
  2. Upon successful login, transfer the access token to the Next.js backend.
  3. Generating a cookie in the backend and sending it back to the front end.
  4. Storing the cookie on the front end.
  5. Implementing middleware on the frontend to include the cookie with every request sent to the backend.
  6. The backend checks the validity of the cookie for each request and redirects to the login page if the cookie is invalid.

I’ve spent hours figuring it out so I hope it will help you save some time.

Protected part of the app

Requirements:

  • A new Next.js project 13.5+ has been created using npx create-react-app my-app.
  • TypeScript and the latest App Router are being used.
  • A Firebase project has been set up.

Step-by-step Implementation:

1. Install Required Packages:

For the frontend:

npm i firebase

For the backend:

npm i firebase-admin

2. Set up Firebase Configuration:

Create two files for configuration:

  • /lib/firebase-admin-config.ts (Backend)
  • /lib/firebase-config.ts (Frontend)

Firebase Admin Config (Backend):

In /lib/firebase-admin-config.ts, paste the following:

import * as admin from "firebase-admin";
import { initializeApp, getApps, cert } from 'firebase-admin/app';
import serviceAccountJson from '@/service-account.json.json'
import { getAuth } from "firebase-admin/auth";

const serviceAccount = serviceAccountJson as admin.ServiceAccount;

const firebaseAdminConfig = {
    credential: cert(serviceAccount)
}

export function customInitApp() {
  if (getApps().length <= 0) {
    return initializeApp({
      credential: applicationDefault(),
    });
  } else {
    return getApps()[0];
  }
}
export const adminAuth = customInitApp();

Notice the customInitApp() this will ensure that no errors such as ‘app/duplicate-app’ will arise when calling firebase-admin.

Make sure to replace serviceAccountJson with the path to your service account key file. The service key can be created on the Google Cloud Console here. The service key is created along with Firebase project.

Place it in src/service-account.json

Firebase Config (Frontend):

In /lib/firebase-config.ts, paste:

import { initializeApp } from "firebase/app";
import { getApps, getApp } from "firebase/app";
import { getAuth } from "firebase/auth";
import { GoogleAuthProvider } from "firebase/auth";
import { CollectionReference, addDoc, collection, doc, getFirestore, setDoc } from "firebase/firestore";
import { query, getDocs, where } from "firebase/firestore";

const firebaseConfig = {
    apiKey: "XXXXXX",
    authDomain: "XXXXXX",
    projectId: "XXXXXX",
    storageBucket: "XXXXXX",
    messagingSenderId: "XXXXXX",
    appId: "1:XXXXXX:web:XXXXXX",
    measurementId: "G-XXXXXX",
};
export const app = getApps().length > 0 ? getApp() : initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);
export const provider = new GoogleAuthProvider();

Replace the placeholders with your Firebase app’s credentials.

You can find this at https://console.firebase.google.com/u/0/ under the ‘Project Overview’ section. Navigate to the ‘General’ tab and look within your web app settings.

App config

3. Implement Authentication API:

In the src/app/api/auth/route.ts file, you'll have the API routes handling authentication. The GET method checks if a request is authenticated, and the POST method handles user sign-in

import { auth } from "firebase-admin";
// import { customInitApp } from "@/lib/firebase-admin-config";
import { cookies, headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";

customInitApp() // Important otherwise you will receive no-app error

export async function GET(request: NextRequest) {
  const session = cookies().get("session")?.value || "";
  //Validate if the cookie exist in the request
  if (!session) {
    return NextResponse.json({ isLogged: false }, { status: 401 });
  }

  //Use Firebase Admin to validate the session cookie
  const decodedClaims = await auth().verifySessionCookie(session, true);

  if (!decodedClaims) {
    return NextResponse.json({ isLogged: false }, { status: 401 });
  }

  return NextResponse.json({ isLogged: true }, { status: 200 });
}

export async function POST(request: NextRequest, response: NextResponse) {
  const authorization = headers().get("Authorization");
  
  if (authorization?.startsWith("Bearer ")) {
    const idToken = authorization.split("Bearer ")[1];
    const decodedToken = await auth().verifyIdToken(idToken);

    if (decodedToken) {
      //Generate session cookie
      const expiresIn = 60 * 60 * 24 * 5 * 1000;
      const sessionCookie = await auth().createSessionCookie(idToken, {
        expiresIn,
      });
      const options = {
        name: "session",
        value: sessionCookie,
        maxAge: expiresIn,
        httpOnly: true,
        secure: true,
      };

      //Add the cookie to the browser
      cookies().set(options);
    }
  }

  return NextResponse.json({}, { status: 200 });
}

In Next.js, naming a file as route.ts within the /api directory makes it accessible via the endpoint http://localhost:3000/api/auth. This naming convention is important for Next.js to recognize and treat the file as a valid API endpoint.

The GET method is designed to verify if a request contains a valid session token. If it doesn’t, it responds with isLogged: false.

This verification will play a crucial role in the middleware.ts we'll set up later, as it will use the GET method to validate each server request.

On the other hand, the POST method manages user sign-ins. If the sign-in credentials are valid, a session gets created on the server and is then sent to the frontend browser to be stored in its headers. Subsequently, with every browser request, this cookie will be sent in the headers. The middleware, using the GET method, will then validate these requests. In essence, this is the authentication process simplified.

4. Set Up Middleware:

The middleware in src/middleware.ts ensures that only authenticated users can access certain routes. It checks for a valid session on every request and redirects unauthenticated users to the login page.

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export async function middleware(request: NextRequest, response: NextResponse) {
  const session = request.cookies.get("session");

  //Return to /login if don't have a session
  if (!session) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  //Call the authentication endpoint
  const responseAPI = await fetch(`${request.nextUrl.origin}/api/auth`, {
    headers: {
      Cookie: `session=${session?.value}`,
    },
  });

  //Return to /login if token is not authorized
  if (responseAPI.status !== 200) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  return NextResponse.next();
}

//Add your protected routes
export const config = {
  matcher: ["/app/:path*"],
};

middleware.ts runs on client side. In case you will run Node code you will receive error: Error: The edge runtime does not support Node.js 'os' module.

For every request to the server, if no session exists, the user is redirected to the login page, as seen in the first line. If a session is detected, the request, along with the session cookie, is sent to the API/off endpoint. Based on the validity of the response, the user may either be redirected to login or to the requested endpoint.

Take note of the config variable with the key “matcher”. It signifies the protected routes of the application, specifically anything following “/app”. If a user attempts to access these routes, their session’s validity is checked. Invalid sessions are redirected to login. Routes not containing “/app” in the URL are accessible to all, ideal for landing pages while reserving certain app sections for logged-in users only.

You can read more about how matcher works here.

5. Design the Login Page:

Using the GoogleAuthProvider, create a login page at src/app/login/page.tsx. Here, users can log in using their Google accounts.

"use client";

import { getRedirectResult, signInWithRedirect } from "firebase/auth";

import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { auth,provider } from "@/lib/firebase-config";

export default function SignIn() {
  const router = useRouter();

  useEffect(() => {
    getRedirectResult(auth).then(async (userCred) => {
      if (!userCred) {
        return;
      }

      fetch("/api/auth", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${await userCred.user.getIdToken()}`,
        },
      }).then((response) => {
        if (response.status === 200) {
          router.push("/app");
        }
      });
    });
  }, []);

  function signIn() {
    signInWithRedirect(auth, provider);
  }

  return (
    <>
      <h2 className="text-3xl uppercase mb-8">Login page</h2>
      <button className="p-4 rounded-lg bg-green-200" onClick={() => signIn()}>Sign In With Google</button>
    </>
  );
}

At the top of the file, note the inclusion of “use client”. This ensures that the component is executed on the front end in Next.js. If you plan to utilize React hooks like useState, useEffect, or useRef, this line is essential.

6. Fetch User Details:

In src/lib/getUser.ts, a helper function checks the session's validity and fetches the user's details.

import { cookies } from "next/headers";
import { adminAuth } from "@/lib/firebase-admin-config";

customInitApp()

//Get the user from the session cookie
//if theres no session or its invalid, return null
const getUser = async ()=> {
  const session = cookies().get("session")?.value;
  if (!session) {
    return null;
  }
  const user = await adminAuth.verifySessionCookie(session, true);
  return user;
}

export default getUser

7. Create the Protected App Page:

The src/app/app/page.tsx page is only accessible to authenticated users. It shows the user's details, and if someone tries to access it without being authenticated, they'll be redirected to the login page.

import getUser from '@/lib/getUser'
import React from 'react'

async function App() {
  const user = await getUser()

  const formattedString = JSON.stringify(user, null, "\t");

  return (
    <div className='mx-auto max-w-4xl my-32 '>
      <h1 className='text-3xl mb-8'>You are now in protected area of the app</h1>
      <p className='mb-4 font-light text-xl'>Here is your information:</p>
      <div className="relative bg-gray-800 p-4 rounded-md shadow-md overflow-x-auto">
        <pre  className="text-sm text-white font-mono">
          <code>{formattedString}</code>
        </pre>
  
      </div>

    </div>
  )
}

export default App

A few things about getUser(). It calls our helper function to decode the session cookie and authenticate against the server, determining which user is accessing the app. Pay attention to the ‘async function.’ This is a server component function introduced in Next.js 13 that supports asynchronous components, allowing the use of ‘await’. This function can run only with server components.

8. Logout — BONUS

To log out a user, we can utilize server actions. Wrap the button in a form and pass a handler function to the action. We will reuse the same endpoint (api/auth) but will need to add a DELETE handler. To log out a user, we will:

  1. Delete the cookie on the front end (browser).
  2. Revoke the refresh token on Firebase.

Unfortunately, it’s not possible to handle everything in the server component as one might expect, and you still need to manage such logic on the API route. First, create a Logout.tsx component.

import React from "react";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

function Logout() {
  async function logOut() {
    "use server";
    const token = cookies().get("session");
    await fetch("http:/localhost:3000/api/auth", {
      method: "DELETE",
      headers: {
        Cookie: `session=${token?.value}`,
      },
    });
    cookies().delete("session");
    redirect("/login");
  }

  return (
    <form action={logOut}>
      <button className="text-white hover:cursor-pointer" type="submit">
        Logout
      </button>
    </form>
  );
}

export default Logout;

Secondly, add the following handler to api/auth.

export async function DELETE(request: NextRequest, response: NextResponse) {
  const token = cookies().get("session")?.value || "";
  if (!token) {
    return NextResponse.json({ isLogged: false }, { status: 401 });
  }
  await invalidateLogin(token);
  return NextResponse.json({}, { status: 200 });
}

// Create a separate file for this utility function if you prefer that way
export const invalidateLogin = async (token: string) => {
  const decodedClaims = await getAuth(adminApp).verifySessionCookie(
    token,
    true
  );
  await getAuth(adminApp).revokeRefreshTokens(decodedClaims.uid);
  cookies().delete("session");
  return;
};

This will revoke the refresh token so it can request new access tokens.

Conclusion:

By integrating Firebase with Next.js 13, developers can efficiently handle user authentication on both the front end and back end, ensuring secure and seamless user experiences.

Thank you for reading, Matija

Inspired by

Stackademic

Thank you for reading until the end. Before you go:

  • Please consider clapping and following the writer! 👏
  • Follow us on Twitter(X), LinkedIn, and YouTube.
  • Visit Stackademic.com to find out more about how we are democratizing free programming education around the world.
Nextjs
Firebase
Full Stack
Serverless
Software Development
Recommended from ReadMedium