Back to blog
·5 min read

Why Your Next.js + Supabase App is Slow: 6 Critical Performance Fixes Developers Miss

Most slow Next.js + Supabase applications have the same performance problems. We break down the 6 critical issues developers miss—and exactly how to fix them.

Why Your Next.js + Supabase App is Slow: 6 Critical Performance Fixes Developers Miss

You've built your Next.js + Supabase app. The schema is clean. Queries run fast in the dashboard. But users are reporting lag. Pages load in 3-5 seconds instead of the 500ms you were targeting. And the worst part? Your monitoring tools aren't screaming—everything looks green.

This is the most common performance failure pattern in production Next.js + Supabase applications. The problems aren't exotic. They're predictable. And once you know what to look for, they're fixable.

Here are the 6 bottlenecks that are silently tanking your app's performance right now.

1. Missing Indexes on RLS-Checked Columns

This is the #1 culprit. When Supabase enforces Row Level Security (RLS), the database must evaluate every single row against your policy before returning results. Without an index on the column used in your RLS check, the query planner performs a full table scan.

Example: You have a policies table with 500,000 rows. Your RLS policy checks `auth.uid() = user_id`. If user_id isn't indexed, Supabase scans all 500k rows every time a user fetches their data.

How to fix it:

  • Identify which columns appear in your RLS policies (usually user_id, org_id, team_id)
  • Add indexes on those columns immediately
  • ```sql

    CREATE INDEX idx_policies_user_id ON policies(user_id);

    CREATE INDEX idx_policies_org_id ON policies(org_id);

    ```

    Then test query performance in the Supabase SQL editor. You should see response times drop by 50-90%.

    2. Direct Database Connections from Serverless Functions

    Serverless functions (Next.js API routes running on Vercel/AWS Lambda) create new database connections on every request. Each connection takes 200-500ms to establish. When your function runs for 100ms but connection overhead is 400ms, you've wasted 80% of your execution time.

    The fix: Use connection pooling. Supabase provides connection pooling via pgbouncer on port 6543 (instead of 5432). Use this endpoint in your serverless environment.

    In your .env.local:

    ```

    NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co

    SUPABASE_SERVICE_ROLE_KEY=your-service-role-key

    DATABASE_URL=postgresql://user:password@your-project-pooler.supabase.co:6543/postgres

    ```

    Connection pooling keeps connections alive between requests, reducing overhead by 70%.

    3. No ISR or Revalidation on Heavy Pages

    If your dashboard pulls data from 3-4 joined tables with RLS checks, it's hitting the database on every page load. That's fine for 10 users. At 100 concurrent users, you're sending 100 simultaneous queries to Supabase during peak traffic.

    Incremental Static Regeneration (ISR) caches expensive pages and revalidates them on a schedule or on-demand.

    Example in app/dashboard/page.tsx:

    ```javascript

    export const revalidate = 60; // Regenerate every 60 seconds

    export default async function DashboardPage() {

    const data = await fetchUserData();

    return <Dashboard data={data} />;

    }

    ```

    For pages that need real-time updates, use on-demand revalidation:

    ```javascript

    'use server'

    export async function revalidateDashboard() {

    revalidateTag('user-dashboard');

    }

    ```

    Then trigger this from a client event or webhook. Pages that previously took 2-3 seconds now serve cached HTML in 50ms.

    4. Unoptimized Images and Media

    Every Next.js + Supabase project eventually stores images in Supabase Storage. If you're serving full-resolution images on cards, lists, or galleries, users are downloading 5-10MB per page load.

    Use Next.js Image component with width/height and sizes:

    ```javascript

    import Image from 'next/image';

    export function ProductCard({ image }) {

    return (

    <Image

    src={image}

    alt="Product"

    width={400}

    height={300}

    sizes="(max-width: 768px) 100vw, 50vw"

    priority={false}

    />

    );

    }

    ```

    Next.js automatically optimizes, compresses, and serves responsive versions. Average page load time drops 1-2 seconds.

    5. Blocking Client-Side Fetches

    This is subtle. Your page loads and renders immediately, but then your React components fire 3-4 useEffect hooks that fetch data sequentially. Each fetch blocks the next one. Users see a spinner for 3 seconds.

    Waterfall fetches:

    ```javascript

    // BAD: Fetch 1, then fetch 2, then fetch 3

    useEffect(() => {

    fetchUser().then(() => fetchOrders()).then(() => fetchInvoices());

    }, []);

    ```

    Parallel fetches:

    ```javascript

    // GOOD: Fetch all three at once

    useEffect(() => {

    Promise.all([fetchUser(), fetchOrders(), fetchInvoices()]);

    }, []);

    ```

    Even better: Fetch on the server:

    ```javascript

    export default async function DashboardPage() {

    const [user, orders, invoices] = await Promise.all([

    fetchUser(),

    fetchOrders(),

    fetchInvoices()

    ]);

    return <Dashboard user={user} orders={orders} invoices={invoices} />;

    }

    ```

    Server-side fetching eliminates client-side waterfalls entirely. Pages that took 4 seconds now load in 1-1.5 seconds.

    6. N+1 Queries Instead of Joins

    Your app fetches user data, then for each user, fetches their organization, then fetches their team members. That's 1 query + N queries + N queries again. At 100 users, you're running 300 queries.

    The fix: Join tables in SQL.

    Instead of:

    ```javascript

    const users = await supabase

    .from('users')

    .select('*')

    .eq('status', 'active');

    for (let user of users) {

    user.organization = await supabase

    .from('organizations')

    .select('*')

    .eq('id', user.org_id)

    .single();

    }

    ```

    Use:

    ```javascript

    const users = await supabase

    .from('users')

    .select(`

    *,

    organizations (

    id,

    name,

    created_at

    )

    `)

    .eq('status', 'active');

    ```

    One query. All data. Supabase handles the joins and RLS enforcement.

    Putting It All Together

    The fastest production Next.js + Supabase apps follow this pattern:

  • Indexes on every RLS-checked column
  • Connection pooling for serverless functions
  • ISR/revalidation on expensive pages
  • Optimized images via Next.js Image
  • Server-side data fetching when possible
  • Joins in SQL, not application code
  • If you're building this from scratch, tools like ZipBuild can scaffold your entire Next.js + Supabase stack with these performance patterns baked in—saving weeks of debugging and optimization work.

    Start with a performance audit: run your slowest page through Chrome DevTools. Identify whether the bottleneck is database queries, image loading, or client-side JavaScript. Then apply the fix above that matches your specific problem.

    Most teams see 60-80% improvements in load time after fixing these six issues.

    Try the free discovery chat at zipbuild.dev to get your Next.js + Supabase architecture reviewed.

    Written by ZipBuild Team

    Ready to build with structure?

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

    Start Building