Authentication state often needs to be available throughout an application. Navigation, profile settings, and authenticated UI may all need access to the currently signed-in user.
In this article, I’ll show how I use Firebase Authentication with React Context in a Next.js application to:
- Make the current user available throughout the app
- Wait for Firebase to restore the existing session
- Handle authentication-based navigation
- Read and update profile data in Firestore
Firebase Authentication handles session persistence. React Context simply makes the resolved authentication state available throughout the component tree.
This example uses Next.js App Router. The same Context approach can be used with Vite, but routing and provider placement may differ.
Creating the Auth Context
Create an auth context such as /context/AuthContext.tsx:
"use client";
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { onAuthStateChanged, type User } from "firebase/auth";
import { Loader2 } from "lucide-react";
import { auth } from "@/firebase/config";
type AuthContextValue = {
user: User | null;
loading: boolean;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
return onAuthStateChanged(auth, (currentUser) => {
setUser(currentUser);
setLoading(false);
});
}, []);
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center">
<Loader2
className="animate-spin"
size={24}
aria-label="Checking authentication status"
/>
</div>
);
}
return (
<AuthContext.Provider value={{ user, loading }}>
{children}
</AuthContext.Provider>
);
}
onAuthStateChanged waits for Firebase to determine whether a session already exists. This prevents the application from treating the initial null value as a confirmed signed-out state.
The returned unsubscribe function also removes the listener when the provider unmounts.
Adding the Provider
Wrap your application with AuthProvider in the root layout:
import "./globals.css";
import type { Metadata } from "next";
import { AuthProvider } from "@/context/AuthContext";
import { ThemeProvider } from "@/components/ThemeProvider";
import Navbar from "@/components/Navbar";
export const metadata: Metadata = {
title: "Firebase Auth with React Context",
description: "Managing Firebase authentication state in Next.js",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<AuthProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<Navbar />
{children}
</ThemeProvider>
</AuthProvider>
</body>
</html>
);
}
The suppressHydrationWarning property is commonly used with theme providers when server and client output may differ.
It suppresses the warning for an expected mismatch. It does not prevent hydration mismatches and is unrelated to Firebase Authentication.
Handling Auth-Based Navigation
Once Firebase has resolved the user state, a client component can redirect users based on their authentication status:
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import PromptLibrary from "@/components/PromptLibrary";
export default function HomePage() {
const { user } = useAuth();
const router = useRouter();
useEffect(() => {
if (!user) {
router.replace("/auth");
return;
}
if (!user.emailVerified) {
router.replace("/verify-email");
}
}, [router, user]);
if (!user || !user.emailVerified) {
return null;
}
return (
<main className="min-h-screen">
<PromptLibrary />
</main>
);
}
There is no need for an arbitrary timeout. The provider has already waited for Firebase to resolve the initial session.
Using router.replace also prevents the restricted route from remaining in the browser history.
Client Redirects Are Not Security
This redirect controls what the user sees, but it does not secure the underlying data.
Firestore documents, API routes, Server Actions, and other protected operations must perform their own authentication and authorisation checks.
For Firestore, this means using Security Rules.
Managing Firestore Profiles
Firebase Authentication stores basic information such as a display name, photo URL, and email address.
Additional profile data can be stored in Firestore:
import {
doc,
getDoc,
serverTimestamp,
setDoc,
type DocumentData,
} from "firebase/firestore";
import { db } from "@/firebase/config";
type UserProfileUpdate = {
displayName?: string;
photoURL?: string;
email?: string;
};
export async function getUserProfile(
userId: string
): Promise<DocumentData | null> {
const snapshot = await getDoc(doc(db, "users", userId));
return snapshot.exists() ? snapshot.data() : null;
}
export async function updateUserProfile(
userId: string,
data: UserProfileUpdate
) {
await setDoc(
doc(db, "users", userId),
{
...data,
updatedAt: serverTimestamp(),
},
{ merge: true }
);
}
Using setDoc with { merge: true } creates the document if it does not exist or updates it without replacing unrelated fields.
It also avoids reading the document before every update.
serverTimestamp() is preferable to new Date() because it does not rely on the user’s device clock.
Updating a User Profile
You can update Firebase Authentication and Firestore from a profile form:
import { updateProfile, type User } from "firebase/auth";
async function saveProfile(
user: User,
displayName: string,
photoURL: string
) {
await updateProfile(user, {
displayName,
photoURL,
});
await updateUserProfile(user.uid, {
displayName,
photoURL,
email: user.email ?? undefined,
});
}
These are two separate requests, so one could succeed while the other fails.
The code attempts to keep both profiles aligned, but it cannot guarantee complete consistency. Production applications should decide which system is the source of truth and handle partial failures appropriately.
Email Verification
Use user.emailVerified for navigation and user messaging.
If a user verifies their email in another tab, reload the Firebase user before checking again:
await user.reload();
if (user.emailVerified) {
router.replace("/");
}
Do not rely on a client-controlled Firestore field such as:
emailVerified: true
for security decisions.
Firestore Security Rules can use Firebase’s trusted authentication claim instead:
request.auth.token.email_verified
Basic Firestore Security Rules
A user profile should at least be restricted to its owner:
rules_version = "2";
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
allow read, create, update: if request.auth != null
&& request.auth.uid == userId;
}
}
}
Production rules may also need to restrict which fields users can update.
Conclusion
Firebase Authentication and React Context provide a straightforward way to share the current user across a Next.js application.
Firebase restores and maintains the session, while the context exposes that state to client components. From there, the application can handle navigation, authenticated UI, and profile forms without passing user data through multiple layers of props.
The important distinction is that client-side auth state controls the interface. Actual security must still be enforced through Firestore Security Rules and trusted server-side checks.
Top comments (2)
Thanks for sharing @ttibbs I am saving this article
No problem, I'm glad you found it useful. Let me know if you have any thoughts or questions when you revisit it.