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

Mukesh @jais_mukesh

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

Location:
Berlin, Germany
Joined:
Jun 17, 2018

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

Publish Date: May 17
1 0

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>
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Root layout fetches the initial user state server-side
  2. This data initializes the AuthProvider
  3. Navbar and other client components access auth state via the context
  4. When auth changes (login/logout), the context updates all components
  5. 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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

Comments 0 total

    Add comment