Managing Supabase Auth State Across Server & Client Components in Next.js


This article intends to save you 10+ hours of your valuable time, effort, and ‘developer’ pain, which I think is an entirely different kind of pain
In your Next.js project, you'll need a way to keep UI elements like your Navbar (a Client component) in sync with Supabase Auth state
You might be thinking - React Context to the rescue.
But wait!
Next.js Authentication Guide states:
React
context
is not supported in Server Components, making them only applicable to Client Components.
Your Navbar is a Client component, seems all good.
But,
Supabase Server-Side Auth recommends access to Auth User data, using only supabase.auth.getUser()
in Server Components.
Example from Supabase Official Guide → Setting up Server-Side Auth for Next.js
// app/private/page.tsx
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export default async function PrivatePage() {
const supabase = await createClient()
const { data, error } = await supabase.auth.getUser()
if (error || !data?.user) {
redirect('/login')
}
return <p>Hello {data.user.email}</p>
}
This creates a disconnect between how you access Auth data on the server versus the client.
So, how can your Client components (like Navbar) & Server components be in sync with the authentication state?
Let's find out.
The Flow Works Like This:
- Root layout fetches the initial user state server-side
- This data initializes the AuthProvider
- Navbar and other client components access auth state via the context
- When auth changes (login/logout), the context updates all components
- Server Components independently verify auth status on each request
1. Server-Side Authentication Layer
First, create a reliable server-side authentication layer:
// utils/auth.ts
import { createClient } from '@/utils/supabase/server';
import { User } from '@supabase/supabase-js';
export async function getUser(): Promise<User | null> {
const supabase = await createClient();
const { data, error } = await supabase.auth.getUser();
if (error || !data?.user) {
return null;
}
return data.user;
}
2. Layout-Based Authentication Sharing
Use Next.js layouts to fetch Auth data from server side once and pass it down:
// app/layout.tsx
import { getUser } from '@/utils/auth';
import Navbar from '@/components/Navbar';
import { AuthProvider } from '@/components/AuthProvider';
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getUser();
return (
<html lang="en">
<body>
<AuthProvider initialUser={user}>
<Navbar />
{children}
</AuthProvider>
</body>
</html>
);
}
3. Client-Side Auth Provider
Create a React Context to watch for Auth change & provide Auth context to all Client components:
// components/AuthProvider.tsx
"use client";
import { createContext, useState, useEffect, useContext, ReactNode } from 'react';
import { User } from '@supabase/supabase-js';
import { createClient } from '@/utils/supabase/client';
type AuthContextType = {
user: User | null;
isLoading: boolean;
};
const AuthContext = createContext<AuthContextType>({
user: null,
isLoading: true,
});
export const useAuth = () => useContext(AuthContext);
export function AuthProvider({
children,
initialUser
}: {
children: ReactNode;
initialUser: User | null;
}) {
const [user, setUser] = useState<User | null>(initialUser);
const [isLoading, setIsLoading] = useState(false);
const supabase = createClient();
useEffect(() => {
// Initialize with SSR data
setUser(initialUser);
setIsLoading(false);
// Listen for auth changes on the client
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
setUser(session?.user || null);
}
);
return () => subscription.unsubscribe();
}, [initialUser]);
return (
<AuthContext.Provider value={{ user, isLoading }}>
{children}
</AuthContext.Provider>
);
}
4. Client-Side Auth Synchronization
For your client components (like Navbar), in addition to consuming Auth Context from AuthProvider, create a mechanism to sync with auth changes:
// components/Navbar.tsx
"use client";
import { useAuth } from '@/components/AuthProvider';
import { signOut } from "app/auth/actions";
import Link from 'next/link';
export default function Navbar() {
const { user } = useAuth();
const supabase = createClient();
const handleSignOut = async () => {
await supabase.auth.signOut();
};
return (
<nav className="p-4 flex justify-between items-center bg-gray-100">
<Link href="/" className="font-bold text-lg">My App</Link>
<div>
{user ? (
<div className="flex items-center gap-4">
<span>Hello, {user.email}</span>
<form action={signOut} className="w-full">
<button
type="submit"
className="w-full text-left"
>
Logout
</button>
</form>
</div>
) : (
<Link
href="/login"
className="px-4 py-2 bg-blue-500 text-white rounded"
>
Sign In
</Link>
)}
</div>
</nav>
);
}
5. Auth State for Server Components
For your server components, use the Supabase recommended pattern. For example:
// app/profile/page.tsx
import { redirect } from 'next/navigation';
import { getUser } from '@/utils/auth';
import ProfileDetails from '@/components/ProfileDetails';
export default async function ProfilePage() {
const user = await getUser();
if (!user) {
redirect('/login');
}
// Fetch additional user data from Supabase if needed
// This can include profile data beyond the auth user
return (
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">Profile</h1>
<ProfileDetails user={user} />
</div>
);
}
// Client Component that receives user data from parent
// components/ProfileDetails.tsx
"use client";
import { User } from '@supabase/supabase-js';
export default function ProfileDetails({ user }: { user: User }) {
return (
<div className="bg-white p-6 rounded shadow">
<h2 className="text-xl mb-4">Your Profile</h2>
<p><strong>Email:</strong> {user.email}</p>
<p><strong>User ID:</strong> {user.id}</p>
<p><strong>Last Sign In:</strong> {new Date(user.last_sign_in_at || '').toLocaleString()}</p>
</div>
);
}
Benefits of This Approach
- Server Components: Can access user data directly via
getUser()
- Client Components: Get real-time auth state via the context hook (
useAuth()
) - No Duplication: Auth logic is centralized and consistent
- Performance: Server-side verification for protected routes
- Seamless UX: UI stays in sync with auth state
This approach gives you the best of both worlds - server-side protection for routes and data access while maintaining a reactive UI that responds to authentication changes
Subscribe to my newsletter
Read articles from Mukesh Jaiswal directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Mukesh Jaiswal
Mukesh Jaiswal
Driving business growth with AI Automation (as Business Automation Partner) | Helping startups build (as Software Developer)