10 Common Mistakes Building with Next.js and Supabase (And How to Fix Them)
technology

10 Common Mistakes Building with Next.js and Supabase (And How to Fix Them)

Avoid these critical mistakes when building with Next.js and Supabase. Learn from real-world errors that cost developers hours of debugging and discover proven solutions.

2026-02-25
12 min read
10 Common Mistakes Building with Next.js and Supabase (And How to Fix Them)

10 Common Mistakes Building with Next.js and Supabase (And How to Fix Them)#

After building dozens of Next.js and Supabase applications, I've seen the same mistakes repeated over and over. These errors cost developers hours of debugging and can lead to security vulnerabilities, performance issues, and frustrated users.

Here are the 10 most common mistakes and how to fix them.

1. Forgetting to Enable RLS#

The Mistake:

You create a table in Supabase and start inserting data without enabling Row Level Security (RLS). Everything works in development, but your data is completely exposed.

-- ❌ Bad: No RLS enabled
CREATE TABLE posts (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  title TEXT,
  content TEXT,
  user_id UUID
);

Why It's Dangerous:

Without RLS, anyone with your anon key can read, modify, or delete ALL data in your table. This is a critical security vulnerability.

The Fix:

Always enable RLS and create appropriate policies:

-- ✅ Good: RLS enabled with policies
CREATE TABLE posts (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  title TEXT,
  content TEXT,
  user_id UUID REFERENCES auth.users(id)
);

ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can view own posts"
  ON posts FOR SELECT
  USING (auth.uid() = user_id);

CREATE POLICY "Users can create posts"
  ON posts FOR INSERT
  WITH CHECK (auth.uid() = user_id);

Pro Tip: Enable RLS by default in your Supabase project settings to prevent this mistake.

2. Using Client-Side Supabase Client in Server Components#

The Mistake:

You import the browser client in a Server Component, causing hydration errors and authentication issues.

// ❌ Bad: Using client in Server Component
import { createClient } from '@/lib/supabase/client'

export default async function Page() {
  const supabase = createClient() // Wrong client!
  const { data } = await supabase.from('posts').select('*')
  return <div>{/* ... */}</div>
}

Why It Breaks:

The browser client expects browser APIs (localStorage, cookies) that don't exist on the server. This causes errors and session management issues.

The Fix:

Use the server client in Server Components:

// ✅ Good: Using server client
import { createClient } from '@/lib/supabase/server'

export default async function Page() {
  const supabase = createClient() // Correct client!
  const { data } = await supabase.from('posts').select('*')
  return <div>{/* ... */}</div>
}

Remember: Server client in Server Components, browser client in Client Components.

3. Not Handling Auth State Changes#

The Mistake:

You check auth once on mount but don't listen for changes, causing stale user state.

// ❌ Bad: Only checking once
'use client'

export function UserProfile() {
  const [user, setUser] = useState(null)
  const supabase = createClient()

  useEffect(() => {
    supabase.auth.getUser().then(({ data }) => {
      setUser(data.user)
    })
    // Missing: onAuthStateChange listener
  }, [])

  return <div>{user?.email}</div>
}

Why It's Problematic:

When users sign in/out in another tab or the session expires, your UI doesn't update. This leads to confusing UX and potential security issues.

The Fix:

Listen for auth state changes:

// ✅ Good: Listening for changes
'use client'

export function UserProfile() {
  const [user, setUser] = useState(null)
  const supabase = createClient()

  useEffect(() => {
    supabase.auth.getUser().then(({ data }) => {
      setUser(data.user)
    })

    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (_event, session) => {
        setUser(session?.user ?? null)
      }
    )

    return () => subscription.unsubscribe()
  }, [])

  return <div>{user?.email}</div>
}

4. Exposing Service Role Key to Client#

The Mistake:

You use the service role key in client-side code to bypass RLS "temporarily."

// ❌ NEVER DO THIS: Service key on client
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY! // DANGER!
)

Why It's Catastrophic:

The service role key bypasses ALL security. Anyone can extract it from your JavaScript bundle and have complete database access.

The Fix:

NEVER expose the service role key. Use it only in:

  • Server-side code (API routes, Server Components)
  • Edge Functions
  • Backend services
// ✅ Good: Service key only on server
// app/api/admin/route.ts
import { createClient } from '@supabase/supabase-js'

export async function POST(request: Request) {
  const supabase = createClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY! // Safe on server
  )
  // Admin operations...
}

5. Not Implementing Middleware for Auth#

The Mistake:

You protect routes by checking auth in each page component, leading to flash of unauthenticated content.

// ❌ Bad: Checking auth in component
export default async function DashboardPage() {
  const supabase = createClient()
  const { data: { user } } = await supabase.auth.getUser()
  
  if (!user) {
    redirect('/login') // Too late - page already rendered
  }

  return <div>Dashboard</div>
}

Why It's Bad:

Users see protected content briefly before redirect. This is poor UX and potentially exposes sensitive data.

The Fix:

Use middleware to protect routes:

// ✅ Good: Middleware protection
// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'

export async function middleware(request: NextRequest) {
  const supabase = createServerClient(/* ... */)
  const { data: { session } } = await supabase.auth.getSession()

  if (!session && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}

6. Ignoring Database Indexes#

The Mistake:

You query large tables without indexes, causing slow queries and timeouts.

-- ❌ Bad: No indexes on frequently queried columns
CREATE TABLE posts (
  id UUID PRIMARY KEY,
  user_id UUID,
  created_at TIMESTAMPTZ,
  title TEXT
);

-- Slow query without index
SELECT * FROM posts WHERE user_id = 'some-uuid' ORDER BY created_at DESC;

Why It's Slow:

PostgreSQL must scan the entire table to find matching rows. With thousands of records, this becomes painfully slow.

The Fix:

Add indexes on columns you query frequently:

-- ✅ Good: Indexes on query columns
CREATE TABLE posts (
  id UUID PRIMARY KEY,
  user_id UUID,
  created_at TIMESTAMPTZ,
  title TEXT
);

CREATE INDEX idx_posts_user_id ON posts(user_id);
CREATE INDEX idx_posts_created_at ON posts(created_at DESC);

-- Now this query is fast
SELECT * FROM posts WHERE user_id = 'some-uuid' ORDER BY created_at DESC;

Rule of Thumb: Index foreign keys, date columns, and any column in WHERE or ORDER BY clauses.

7. Not Using TypeScript Types from Supabase#

The Mistake:

You manually type your database queries, leading to type mismatches and runtime errors.

// ❌ Bad: Manual typing
interface Post {
  id: string
  title: string
  content: string
  // Oops, forgot user_id and created_at
}

const { data } = await supabase.from('posts').select('*')
const posts = data as Post[] // Type doesn't match reality

Why It's Problematic:

Your types drift from your actual database schema, causing runtime errors that TypeScript can't catch.

The Fix:

Generate types from your database:

npx supabase gen types typescript --project-id your-project-ref > types/database.types.ts
// ✅ Good: Generated types
import { Database } from '@/types/database.types'

type Post = Database['public']['Tables']['posts']['Row']

const { data } = await supabase.from('posts').select('*')
// data is correctly typed as Post[]

8. Fetching Data in Client Components Unnecessarily#

The Mistake:

You fetch data in Client Components when Server Components would work better.

// ❌ Bad: Client-side fetching
'use client'

export function PostsList() {
  const [posts, setPosts] = useState([])
  const [loading, setLoading] = useState(true)
  const supabase = createClient()

  useEffect(() => {
    supabase.from('posts').select('*').then(({ data }) => {
      setPosts(data || [])
      setLoading(false)
    })
  }, [])

  if (loading) return <div>Loading...</div>
  return <div>{/* render posts */}</div>
}

Why It's Worse:

  • Slower (client-side fetch after hydration)
  • More JavaScript sent to browser
  • Loading states needed
  • Not SEO-friendly

The Fix:

Use Server Components for data fetching:

// ✅ Good: Server-side fetching
import { createClient } from '@/lib/supabase/server'

export default async function PostsList() {
  const supabase = createClient()
  const { data: posts } = await supabase.from('posts').select('*')

  return <div>{/* render posts */}</div>
}

Use Client Components only when you need:

  • User interactions (onClick, onChange)
  • Browser APIs (localStorage, geolocation)
  • React hooks (useState, useEffect)

9. Not Handling Realtime Subscription Cleanup#

The Mistake:

You subscribe to realtime changes but forget to unsubscribe, causing memory leaks.

// ❌ Bad: No cleanup
'use client'

export function Messages() {
  const [messages, setMessages] = useState([])
  const supabase = createClient()

  useEffect(() => {
    const channel = supabase
      .channel('messages')
      .on('postgres_changes', { event: '*', schema: 'public', table: 'messages' },
        (payload) => setMessages((m) => [...m, payload.new])
      )
      .subscribe()
    
    // Missing: cleanup function
  }, [])

  return <div>{/* ... */}</div>
}

Why It's Bad:

Subscriptions stay active even after component unmounts, causing memory leaks and duplicate subscriptions.

The Fix:

Always clean up subscriptions:

// ✅ Good: Proper cleanup
'use client'

export function Messages() {
  const [messages, setMessages] = useState([])
  const supabase = createClient()

  useEffect(() => {
    const channel = supabase
      .channel('messages')
      .on('postgres_changes', { event: '*', schema: 'public', table: 'messages' },
        (payload) => setMessages((m) => [...m, payload.new])
      )
      .subscribe()
    
    return () => {
      supabase.removeChannel(channel)
    }
  }, [])

  return <div>{/* ... */}</div>
}

10. Not Using Database Transactions#

The Mistake:

You perform multiple related database operations without transactions, risking data inconsistency.

// ❌ Bad: No transaction
async function transferCredits(fromUserId: string, toUserId: string, amount: number) {
  // Deduct from sender
  await supabase
    .from('users')
    .update({ credits: credits - amount })
    .eq('id', fromUserId)

  // If this fails, sender loses credits but receiver doesn't get them!
  await supabase
    .from('users')
    .update({ credits: credits + amount })
    .eq('id', toUserId)
}

Why It's Dangerous:

If the second operation fails, you have inconsistent data. The sender lost credits but the receiver didn't gain them.

The Fix:

Use database functions with transactions:

-- ✅ Good: Transaction in database function
CREATE OR REPLACE FUNCTION transfer_credits(
  from_user_id UUID,
  to_user_id UUID,
  amount INT
)
RETURNS VOID AS $
BEGIN
  -- Both operations succeed or both fail
  UPDATE users SET credits = credits - amount WHERE id = from_user_id;
  UPDATE users SET credits = credits + amount WHERE id = to_user_id;
END;
$ LANGUAGE plpgsql;
// Call from Next.js
await supabase.rpc('transfer_credits', {
  from_user_id: fromUserId,
  to_user_id: toUserId,
  amount: amount,
})

Bonus: Quick Checklist#

Before deploying your Next.js + Supabase app, verify:

  • [ ] RLS enabled on all tables
  • [ ] Appropriate RLS policies created
  • [ ] Using correct Supabase client (server vs browser)
  • [ ] Auth state change listeners implemented
  • [ ] Service role key never exposed to client
  • [ ] Middleware protecting sensitive routes
  • [ ] Database indexes on frequently queried columns
  • [ ] TypeScript types generated from database
  • [ ] Server Components used for data fetching
  • [ ] Realtime subscriptions properly cleaned up
  • [ ] Transactions used for related operations
  • [ ] Environment variables properly configured
  • [ ] Error handling implemented
  • [ ] Loading states for async operations

Frequently Asked Questions (FAQ)#

What happens if I forget to enable RLS on a table?#

Without RLS, anyone with your anon key can read, modify, or delete ALL data in that table. This is a critical security vulnerability. Always enable RLS: ALTER TABLE table_name ENABLE ROW LEVEL SECURITY; and create appropriate policies.

Can I use the browser Supabase client in Server Components?#

No, the browser client expects browser APIs (localStorage, cookies) that don't exist on the server. Always use the server client (@/lib/supabase/server) in Server Components and the browser client (@/lib/supabase/client) in Client Components.

How do I know if I'm using the wrong Supabase client?#

You'll see errors like "localStorage is not defined" or "cookies is not a function" if using the wrong client. Server Components should use createClient() from @/lib/supabase/server, Client Components from @/lib/supabase/client.

Is it ever safe to expose the service role key?#

Never expose the service role key to the client. It bypasses ALL security including RLS. Only use it in server-side code (API routes, Server Components, Edge Functions) where it can't be extracted by users.

Why do my auth sessions disappear after refresh?#

This usually happens when you're not properly handling cookies in middleware or using the wrong Supabase client. Implement middleware that refreshes sessions and use the server client in Server Components.

How do I add indexes to existing tables?#

Use SQL: CREATE INDEX idx_table_column ON table_name(column_name);. Add indexes on foreign keys, columns in WHERE clauses, and columns in ORDER BY. Check query performance with EXPLAIN ANALYZE.

Should I generate TypeScript types for my database?#

Yes! Run npx supabase gen types typescript --project-id your-ref > types/database.types.ts. This ensures your TypeScript types match your actual database schema, preventing runtime errors.

When should I use Client Components vs Server Components?#

Use Server Components by default for better performance. Only use Client Components when you need interactivity (onClick, onChange), React hooks (useState, useEffect), or browser APIs (localStorage, window).

How do I prevent memory leaks from Realtime subscriptions?#

Always clean up subscriptions in useEffect cleanup: return () => { supabase.removeChannel(channel) }. This unsubscribes when the component unmounts, preventing memory leaks.

Use database functions with transactions for related operations (like transferring credits between users). This ensures all operations succeed or all fail together, maintaining data consistency.

How can I test if my RLS policies are working?#

Test RLS by creating test users, signing in as different users, and attempting to access/modify data. Use SET request.jwt.claim.sub = 'user-id' in SQL to test policies directly in the database.

Should I use middleware for all protected routes?#

Yes, middleware is the best place to protect routes. It runs before the page renders, preventing flash of unauthenticated content and providing better UX than checking auth in each component.

Conclusion#

These mistakes are easy to make but costly to fix in production. By following these patterns, you'll build more secure, performant, and maintainable applications.

Remember: security first (RLS), use the right tools (server vs client), and clean up after yourself (subscriptions, transactions).

Have you made any of these mistakes? What other pitfalls have you encountered? Share your experiences in the comments!

Frequently Asked Questions

|

Have more questions? Contact us