Back to blog
·6 min read

How to Structure Supabase and Next.js 15 Projects for SSR Authentication Without Hydration Errors

Server-side authentication in Next.js 15 looks simple until hydration errors destroy your app at runtime. Here's the exact project structure and code patterns that eliminate these issues when using Supabase with App Router.

The Supabase + Next.js 15 Authentication Problem Nobody Warns You About

You set up Supabase authentication, follow the docs, deploy to production—and then users report being logged out randomly or seeing a flash of the login page before the authenticated UI loads. The console shows no errors. Your code looks correct. But something about how Next.js 15 handles server components and client state is fighting with how Supabase manages sessions.

This isn't a bug in either library. It's an architecture problem.

The issue happens because Supabase's JavaScript client expects to manage session state in the browser, but Next.js 15's App Router encourages server-first thinking. When you mix server components that need authentication data with client components that manage state, you create a window where the server and client have different session information. That gap is where hydration errors and session leaks live.

The solution requires understanding how to structure your authentication layer so server and client stay in sync without one fighting the other.

Why Hydration Errors Happen with Auth

Hydration errors occur when the HTML the server renders doesn't match the HTML the browser creates on first load. With authentication, this typically happens in this sequence:

  • Server renders a page and checks the session. It finds a valid session and renders the authenticated UI.
  • Browser loads the same page and JavaScript runs. The Supabase client checks localStorage and hasn't loaded the session yet.
  • For a split second, the client renders the unauthenticated UI while the server had rendered the authenticated UI.
  • React detects the mismatch and throws a hydration error.
  • The Supabase team created the @supabase/ssr package specifically to solve this. But most developers use it wrong. They treat it as a drop-in replacement for the regular Supabase client, when actually it's a synchronization layer that needs careful structural choices.

    The Correct Architecture Pattern

    Your authentication needs three distinct layers that communicate cleanly:

    Authentication Utilities Layer

    Create a dedicated file that handles all Supabase client instantiation. This is where @supabase/ssr comes in, but you need to separate server and browser logic.

    Create lib/supabase.ts:

    ```

    import { createBrowserClient } from '@supabase/ssr'

    import { createServerClient } from '@supabase/ssr'

    import { cookies } from 'next/headers'

    export function createClient() {

    const cookieStore = cookies()

    return createServerClient(

    process.env.NEXT_PUBLIC_SUPABASE_URL!,

    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,

    {

    cookies: {

    getAll() {

    return cookieStore.getAll()

    },

    setAll(cookiesToSet) {

    try {

    cookiesToSet.forEach(({ name, value, options }) =>

    cookieStore.set(name, value, options)

    )

    } catch {

    // Handle errors during SSR

    }

    },

    },

    }

    )

    }

    export function createBrowserClient() {

    return createBrowserClient(

    process.env.NEXT_PUBLIC_SUPABASE_URL!,

    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!

    )

    }

    ```

    The key difference: the server version uses Next.js cookies() to persist session state across requests. The browser version uses localStorage and the browser's native cookie handling.

    Authentication Context Layer

    Never pass raw Supabase clients to components. Instead, create a context that handles the session state and prevents mismatches:

    ```

    'use client'

    import { createContext, useContext, useEffect, useState } from 'react'

    import { User } from '@supabase/supabase-js'

    import { createBrowserClient } from '@/lib/supabase'

    type AuthContextType = {

    user: User | null

    loading: boolean

    error: Error | null

    }

    const AuthContext = createContext<AuthContextType | undefined>(undefined)

    export function AuthProvider({ children }: { children: React.ReactNode }) {

    const [user, setUser] = useState<User | null>(null)

    const [loading, setLoading] = useState(true)

    const [error, setError] = useState<Error | null>(null)

    useEffect(() => {

    const supabase = createBrowserClient()

    supabase.auth.onAuthStateChange((event, session) => {

    setUser(session?.user ?? null)

    setError(null)

    setLoading(false)

    })

    supabase.auth.getSession().then(({ data, error }) => {

    if (error) setError(error)

    setUser(data.session?.user ?? null)

    setLoading(false)

    })

    }, [])

    return (

    <AuthContext.Provider value={{ user, loading, error }}>

    {children}

    </AuthContext.Provider>

    )

    }

    export function useAuth() {

    const context = useContext(AuthContext)

    if (!context) {

    throw new Error('useAuth must be used within AuthProvider')

    }

    return context

    }

    ```

    The context listens for auth state changes once on the client and keeps all components in sync. This prevents the situation where different parts of your app have different session data.

    Protected Route Structure

    On the server side, create a utility that checks authentication before rendering sensitive pages:

    ```

    import { redirect } from 'next/navigation'

    import { createClient } from '@/lib/supabase'

    export async function requireAuth() {

    const supabase = createClient()

    const { data: { user }, error } = await supabase.auth.getUser()

    if (error || !user) {

    redirect('/login')

    }

    return user

    }

    ```

    In your layout or page:

    ```

    import { requireAuth } from '@/lib/auth'

    export default async function DashboardLayout({

    children,

    }: {

    children: React.ReactNode

    }) {

    const user = await requireAuth()

    return (

    <div>

    <header>Welcome, {user.email}</header>

    {children}

    </div>

    )

    }

    ```

    The server checks authentication before rendering anything. No hydration mismatch because the client-side AuthContext will have the same user data the server confirmed.

    Avoiding Session Leaks

    Session leaks happen when you store session tokens in places the server can't clear. Always store authentication state in cookies (which the server manages) rather than localStorage alone.

    The Supabase SSR package handles this automatically when configured correctly, but verify your middleware is updating cookies after auth changes:

    ```

    import { type NextRequest, NextResponse } from 'next/server'

    import { createServerClient } from '@supabase/ssr'

    export async function middleware(request: NextRequest) {

    let response = NextResponse.next({

    request: {

    headers: request.headers,

    },

    })

    const supabase = createServerClient(

    process.env.NEXT_PUBLIC_SUPABASE_URL!,

    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,

    {

    cookies: {

    getAll() {

    return request.cookies.getAll()

    },

    setAll(cookiesToSet) {

    cookiesToSet.forEach(({ name, value, options }) => {

    response.cookies.set(name, value, options)

    })

    },

    },

    }

    )

    await supabase.auth.getUser()

    return response

    }

    ```

    This middleware runs on every request and keeps session cookies synchronized across the server and client.

    Building This Structure with AI Assistance

    When you have a clear architecture in mind, AI tools like Claude Code can scaffold this pattern quickly and consistently across your project. The key is giving Claude Code specific constraints: use App Router, server-side session checks, cookie-based storage, no localStorage for sensitive data.

    If you're building a production SaaS from scratch, this authentication structure is foundational. Getting it wrong creates technical debt that compounds through your entire codebase. ZipBuild can generate this exact pattern along with your full project structure, so you start with a secure foundation instead of discovering auth problems after you've already built features on top of broken assumptions.

    The Pattern That Actually Works

    The separation matters. Your server validates sessions independently. Your client stays in sync via context. Protected routes enforce authentication before rendering. Cookies persist state across requests without leaking tokens to places the server can't manage.

    This is how major platforms handle it. It's not complicated once you see the three layers working together.

    Setup takes an afternoon. It saves weeks of debugging hydration issues in production.

    Try the free discovery chat at zipbuild.dev to see how to scaffold this architecture for your specific SaaS requirements.

    Written by ZipBuild Team

    Ready to build with structure?

    Try the free discovery chat and see how ZipBuild architects your idea.

    Start Building