Back to blog
·6 min read

Supabase and Next.js Client vs Server Client: 5 Mistakes Costing You Hours

Building with Supabase and Next.js is fast—until you mix up client and server clients. We break down the 5 most expensive mistakes developers make, and show you exactly how to fix them.

You've got a Next.js app. You've added Supabase. Everything works locally. Then you deploy, or you add a second feature, and suddenly nothing makes sense.

The data is leaking. Your auth state breaks when users open two tabs. Your API calls are 50x slower than expected. You're digging through GitHub issues at 2am wondering why the official Supabase docs don't mention any of this.

This is the Supabase + Next.js experience for most developers—not because Supabase is bad, but because the architecture decisions you make on day one either scale smoothly or become a complete rewrite at month six.

Here are the five mistakes that cost developers the most time, and exactly how to avoid them.

Mistake 1: Using the Browser Client in Server Components

This is the number one error. It happens because Supabase's JavaScript client looks the same everywhere, but it behaves completely differently on the server vs the browser.

The browser client (createClient) expects browser APIs. It reads from localStorage for session tokens. It uses browser cookies. It assumes certain security contexts. On the server, none of these things exist. You get cryptic errors. Sessions vanish. Your app breaks at weird times.

Here's what developers do wrong:

```

// This will fail. Don't do this in Server Components

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

export default async function Page() {

const supabase = createClient(url, key)

const { data } = await supabase.from('users').select()

return <div>{data}</div>

}

```

The createClient function is for the browser. On the server, it tries to access localStorage, which doesn't exist. Your page crashes or behaves unpredictably.

The fix: Create two separate client instances.

For the server (in your server actions or API routes):

```

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

export async function getUsers() {

const supabase = createClient(

process.env.NEXT_PUBLIC_SUPABASE_URL,

process.env.SUPABASE_SERVICE_ROLE_KEY

)

const { data } = await supabase.from('users').select()

return data

}

```

The service role key has full database access. Use it only on the server where it's protected.

For the browser (in a hook or client component):

```

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

export const supabase = createClient(

process.env.NEXT_PUBLIC_SUPABASE_URL,

process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY

)

```

The anon key is public. It's restricted by Row Level Security (RLS) policies, which brings us to mistake two.

Mistake 2: Skipping Row Level Security Policies

You create a table. You insert some data. Your app works. You never enable RLS.

Without RLS, anyone with your anon key can query, insert, update, or delete every row in every table. This sounds obvious when you read it, but it's easy to skip during development because everything works fine without it.

Then you deploy. Your data is public. Your users' information is readable by anyone. You don't realize until months later when someone points it out.

RLS is not optional. It's your actual security layer when using the anon key.

Start here: In your Supabase dashboard, go to each table, click the RLS icon, and enable it. This immediately locks down the table and requires policies.

A basic policy for a users table:

```

create policy "Users can read their own data"

on public.users

for select

using (auth.uid() = id);

create policy "Users can update their own data"

on public.users

for update

using (auth.uid() = id);

```

This says: a user can only select or update rows where their auth ID matches the user ID column.

Without this, your app still works during development (because you're logged in). But the data is unprotected. Add RLS early, test it, and never skip it again.

Mistake 3: Auth State Breaking Across Tabs

User opens your app in two tabs. They sign out in tab one. Tab two still thinks they're logged in. They click a button, it fails mysteriously, and they refresh the page.

This happens because each tab maintains its own session state. Supabase doesn't automatically sync between them.

The fix: Listen for auth changes and refresh your app state when they occur.

```

import { useEffect, useState } from 'react'

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

export function useAuth() {

const [session, setSession] = useState(null)

const [loading, setLoading] = useState(true)

useEffect(() => {

const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {

setSession(session)

setLoading(false)

// Optionally: refresh page on signout to clear all state

if (event === 'SIGNED_OUT') {

window.location.reload()

}

})

return () => subscription.unsubscribe()

}, [])

return { session, loading }

}

```

This listener fires when the auth state changes in any tab. Your app stays in sync across browser windows.

Mistake 4: N+1 Query Problems

Your dashboard displays 10 users. For each user, you fetch their profile data. That's 1 query for the list + 10 queries for profiles = 11 queries total.

If you have 100 users, that's 101 queries. Your page takes 5 seconds to load.

The fix: Use joins. Fetch everything in one query.

```

const { data } = await supabase

.from('users')

.select('id, name, profiles(*)')

.limit(10)

```

This fetches users and their related profiles in a single database round trip.

Mistake 5: Connection Pooling at Scale

Supabase databases have connection limits. By default, you get around 20 concurrent connections. When you hit that limit, new queries queue or fail.

In development, you never notice. In production with real traffic, your app mysteriously hangs.

The fix: For server-side database operations, use pgBouncer (connection pooling). For client-side, ensure you're using the anon key with RLS policies instead of hammering the database with unlimited connections.

Putting It All Together

The pattern that works:

  • Use the service role client in server actions and API routes
  • Use the anon client in the browser, protected by RLS policies
  • Enable RLS on every table immediately
  • Listen for auth changes to keep sessions in sync
  • Use joins to avoid N+1 queries
  • Enable connection pooling when you deploy
  • This isn't just best practice—it's the difference between an app that scales and one that becomes a nightmare at 1,000 users.

    If you're building a full Next.js and Supabase application and want the architecture set up correctly from the start, ZipBuild generates production-ready scaffolding with all these patterns baked in. You get the security, the auth handling, and the database structure done right on day one.

    The days of debugging auth state at 2am are over when you start with a solid foundation.

    Try the free discovery chat at zipbuild.dev to see how a properly structured Supabase + Next.js project saves you weeks of debugging.

    Written by ZipBuild Team

    Ready to build with structure?

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

    Start Building