Supabase with Next.js SSR: Auth and Data Fetching

The most surprising thing about Supabase with Next.js SSR is that you’re not actually running Supabase on your Next.js server; you’re running it from your Next.js server, and the distinction is crucial for understanding how auth and data fetching work.

Let’s see this in action. Imagine a simple Next.js page that needs to fetch user-specific data.

// pages/dashboard.jsx
import { useState, useEffect } from 'react';
import { supabase } from '../utils/supabaseClient'; // Assume this is configured

function DashboardPage() {
  const [userProfile, setUserProfile] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function fetchUserData() {
      setLoading(true);
      // This is a client-side fetch after the SSR context
      const { data: profile, error } = await supabase
        .from('profiles')
        .select('username, avatar_url')
        .eq('id', supabase.auth.user()?.id) // Accessing user from client-side auth
        .single();

      if (error) console.error('Error fetching profile:', error);
      else setUserProfile(profile);
      setLoading(false);
    }
    fetchUserData();
  }, []); // Empty dependency array means this runs once on mount

  if (loading) return <p>Loading dashboard...</p>;
  if (!userProfile) return <p>No profile found.</p>;

  return (
    <div>
      <h1>Welcome, {userProfile.username}!</h1>
      {userProfile.avatar_url && <img src={userProfile.avatar_url} alt="Avatar" />}
      {/* Other dashboard content */}
    </div>
  );
}

export default DashboardPage;

In this client-side useEffect hook, supabase.auth.user() is being called. This works because the supabaseClient instance, when initialized, likely has session information persisted from the client-side or has been refreshed. The key is that the Supabase SDK on the client is managing the user’s session and making authenticated requests.

Now, let’s look at a Server-Side Rendering (SSR) context, specifically within getServerSideProps.

// pages/dashboard.jsx (continued)
export async function getServerSideProps(context) {
  const req = context.req; // Accessing the request object
  const { data: { user } } = await supabase.auth.api.getUserByCookie(req); // Supabase handles cookie extraction

  if (!user) {
    // If no user is found on the server, redirect to login
    return {
      redirect: {
        destination: '/login',
        permanent: false,
      },
    };
  }

  // Fetching data *on the server* using the user's ID
  const { data: profile, error } = await supabase
    .from('profiles')
    .select('username, avatar_url')
    .eq('id', user.id) // Using the user ID obtained from the server context
    .single();

  if (error) {
    console.error('Server-side profile fetch error:', error);
    // Handle error appropriately, maybe return an error prop
    return { props: { error: 'Failed to load profile' } };
  }

  return {
    props: {
      initialUserProfile: profile, // Pass fetched data to the page component
      userEmail: user.email, // You can pass other user data too
    },
  };
}

// ... DashboardPage component would then receive initialUserProfile and userEmail as props

The problem this solves is providing authenticated and personalized content without exposing sensitive API keys or making the client-side wait for initial data. getServerSideProps runs before the page is sent to the browser. Supabase’s getUserByCookie(req) is the magic here: it inspects the incoming HTTP request, finds the Supabase auth cookie (usually sb-auth-token), and uses it to authenticate the user on the server. This allows you to make data queries directly tied to that authenticated user’s identity.

Internally, when getUserByCookie(req) is called, the Supabase client library on your Next.js server is essentially making a request to your Supabase project’s API endpoint. It’s validating the token against your Supabase project, retrieving the user’s session information, and making that available. Then, subsequent .from('profiles').select(...) calls within getServerSideProps are automatically authenticated using the session information derived from that cookie.

The exact levers you control are:

  1. supabase.auth.api.getUserByCookie(req): This is how you extract the authenticated user from the server-side request. It’s crucial for any SSR/SSG page that requires user context.
  2. Database Policies: Your Supabase database must have Row Level Security (RLS) policies configured. These policies determine what data a user (or an anonymous user) can access. For example, a policy on the profiles table might look like SELECT * FROM profiles WHERE id = auth.uid();. This ensures that a user can only fetch their own profile data, even if the query is made on the server.
  3. supabaseClient Initialization: How you initialize your supabaseClient matters. For SSR, you often need a way to pass the server-side user context to the client-side if you intend to use the same client instance for both. A common pattern is to rehydrate the client-side Supabase client with the user data obtained in getServerSideProps.

The one thing most people don’t know is that the supabaseClient instance you typically export from utils/supabaseClient is often stateless between server requests. Each invocation of getServerSideProps (or getStaticProps with revalidation) gets a fresh environment. This is why getUserByCookie(req) is essential; you’re not inheriting a session from a previous request. The client library on the server is responsible for reading the cookie and establishing the user context for that specific server render.

The next concept you’ll likely encounter is managing the Supabase client’s state across client-side navigation after an SSR page has loaded.

Want structured learning?

Take the full Supabase course →