Have you ever struggled to maintain persistent user data across your app, whether for authentication or other contexts? If so, you're not alone! In this post, I'll share my current approach to tackling this challenge in Next.js or Vite apps.
User authentication is a cornerstone of modern web applications, and managing it efficiently can significantly improve both security and developer experience. I’ll walk you through how I use React Context with Firebase to handle authentication, protect routes, and manage user profiles without unnecessary complexity.
Whether you're just starting out or already experienced, these techniques will help you build a robust and scalable authentication system. Here's how to implement it, step by step.
Setting Up the AuthContext
At the core of our authentication system is the AuthContext. This React Context centralises user authentication logic, ensuring every component has seamless access to the current user state without prop drilling.
Prerequisites
Before diving into the implementation, ensure you have:
✅ A Firebase project set up
✅ A web app created within Firebase
✅ Authentication enabled
✅ Firestore configured for storing user data
Once that’s in place, let’s build our AuthContext.
Creating the AuthContext
Below is the implementation of /app/contexts/AuthContext.tsx
:
"use client";
import React, { createContext, useContext, useState, useEffect } from "react";
import { auth } from "../firebase/config";
import { onAuthStateChanged, User } from "firebase/auth";
import { AuthContextType, AuthProviderProps } from "@/types";
import { Loader2 } from "lucide-react";
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
setUser(user);
setLoading(false);
});
return () => unsubscribe();
}, []);
// Show a simple loading state
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<Loader2 className="animate-spin text-foreground" size={24} />
</div>
);
}
return (
<AuthContext.Provider value={{ user, loading }}>
{children}
</AuthContext.Provider>
);
};
Key Takeaways
-
Context Creation: The
AuthContext
is initialised with an undefined default value and later provided with the actual authentication state. -
Custom Hook (
useAuth
): A helper function that simplifies accessing the auth state and ensures correct usage within the provider. -
Real-Time Authentication: The
useEffect
hook listens for Firebase auth state changes and updates the user state accordingly. - Loading State: A spinner is displayed while authentication status is being determined, preventing unnecessary flickering in the UI.
Integrating AuthProvider in Your Application
To ensure authentication state is available across your app, wrap your root layout with the AuthProvider. This allows all components to access the user state seamlessly.
Here’s how it’s done in /app/layout.tsx
:
import "./globals.css";
import type { Metadata } from "next";
import { Montserrat } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/ThemeProvider";
import Navbar from "@/components/Navbar";
import { AuthProvider } from "@/context/AuthContext";
const montserrat = Montserrat({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Auth Context + Firebase",
description: "State of Auth Made Simple",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${montserrat.className}`}>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-0 focus:left-0 focus:z-50 focus:p-4 bg-background"
>
Skip to main content
</a>
<AuthProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<Navbar />
{children}
<Toaster closeButton />
</ThemeProvider>
</AuthProvider>
</body>
</html>
);
}
Highlights
-
Global Accessibility: By wrapping your app with
AuthProvider
, every component can access the auth state. -
Hydration Considerations: The
suppressHydrationWarning
property in the<html>
tag prevents hydration mismatches, improving client-side rendering stability. -
Seamless Integration: The auth state is integrated alongside other providers (like the
ThemeProvider
), keeping the application structure clean and modular.
Protecting Routes with Client-Side Redirection
So, how do we ensure only the right users access the right pages? Let’s explore a simple, effective approach. Below is how I enforced authentication and email verification using React hooks:
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import PromptLibrary from "@/components/PromptLibrary";
export default function Home() {
const { user } = useAuth();
const router = useRouter();
useEffect(() => {
const timeout = setTimeout(() => {
if (!user) {
router.push("/auth");
} else if (!user.emailVerified) {
router.push("/verify-email");
}
}, 100);
return () => clearTimeout(timeout);
}, [user, router]);
if (!user || !user.emailVerified) {
return null;
}
return (
<main className="min-h-screen bg-background" aria-label="Main content">
<PromptLibrary />
</main>
);
}
Explanation
-
Authentication Check: The
useEffect
hook verifies if a user is authenticated and whether their email is verified. -
Automatic Redirection: Unauthenticated users are redirected to
/auth
, while users with unverified emails are sent to/verify-email
. -
Graceful Handling: A short delay (
setTimeout
) ensures the user state is settled before triggering a redirection, preventing unnecessary flickers or race conditions. -
Minimal UI Flashing: The component returns
null
if the user is not authenticated, avoiding unnecessary renders before navigation.
Managing User Profiles with Firebase
Firebase provides an excellent backend solution not just for authentication, but also for managing and storing user data. Below, you'll find two helper functions to fetch and update user profiles using Firestore:
Fetching a User Profile
To fetch a user’s profile from Firestore, add these functions to your /app/firebase/config.ts
file:
export const getUserProfile = async (userId: string) => {
const userRef = doc(db, "users", userId);
const userDoc = await getDoc(userRef);
if (userDoc.exists()) {
return userDoc.data();
}
return null;
};
export const updateUserProfile = async (
userId: string,
data: {
displayName?: string;
photoURL?: string;
email?: string;
emailVerified?: boolean;
}
) => {
const userRef = doc(db, "users", userId);
const userDoc = await getDoc(userRef);
if (!userDoc.exists()) {
// Create new user document if it doesn't exist
await setDoc(userRef, {
...data,
createdAt: new Date(),
updatedAt: new Date(),
emailVerified: data.emailVerified ?? false,
});
} else {
// Update existing user document
await updateDoc(userRef, {
...data,
updatedAt: new Date(),
});
}
};
Function highlights
-
Document Check: Before updating,
updateUserProfile
checks whether the document exists. If not, it creates a new one. -
Fetching Data:
getUserProfile
retrieves the user document from Firestore, returning the data if it exists, ornull
if not. -
Efficient Updates: Both functions ensure that user profiles are either created or updated correctly with the current timestamp (
createdAt
andupdatedAt
), keeping your Firestore data in sync with the latest user information.
Integrating Firebase in React Components
Now that everything is in place, you can integrate Firebase into a user-facing component. In the example below I built a profile settings dialog with ShadCN, where users can update their profile information:
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { updateProfile } from "firebase/auth";
import { toast } from "sonner";
import { updateUserProfile } from "@/firebase/config";
import { ProfileSettingsDialogProps } from "@/types";
export function ProfileSettingsDialog({
user,
isOpen,
onOpenChange,
}: ProfileSettingsDialogProps) {
const [displayName, setDisplayName] = useState(user.displayName || "");
const [photoURL, setPhotoURL] = useState(user.photoURL || "");
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
// Update Firebase Auth profile
await updateProfile(user, {
displayName,
photoURL,
});
// Update Firestore user profile
await updateUserProfile(user.uid, {
displayName,
photoURL,
email: user.email || undefined,
});
toast.success("Profile updated successfully");
onOpenChange(false);
} catch (error) {
toast.error("Failed to update profile");
console.error("Error updating profile:", error);
} finally {
setIsLoading(false);
}
};
// Render the dialog UI here...
}
What This Component Does
-
State Management: The component maintains local state for
displayName
andphotoURL
, allowing for controlled inputs and smooth state changes. - Profile Updates: Upon form submission, it synchronizes the user's profile across both Firebase Authentication and Firestore.
- User Feedback: The component provides clear success and error notifications using toast messages, ensuring users are informed of the outcome.
By using this component, users can easily update their profile details, with changes reflected both in Firebase Auth and Firestore, ensuring data consistency.
Conclusion
By integrating React Context with Firebase, you can create a seamless authentication experience in your Next.js applications. This approach not only centralises user state management but also simplifies route protection and profile handling. With modular code and a clear separation of concerns, your app is set up to scale as your user base grows.
These strategies will enhance your app’s security, reliability, and user experience—benefitting both developers and end users alike.
I’d love to hear your thoughts! Feel free to share any feedback or experiences in the comments. Thanks for reading, and happy coding!
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.