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.
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.
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.
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
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.
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:
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
next-firebase-auth-edge
and initial configurationAdd 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
};
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.
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!
firebase
and initialising Firebase Client SDKRun 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
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:
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'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:
/register
and /login
pages, we should redirect them to /
/
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
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! 🎉
This bonus guide will teach you how to deploy your Next.js app to Vercel
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
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! 🎉