Next.js + Supabase Performance: 10 Real Fixes
Ten fixes for slow Next.js + Supabase apps, each grounded in the official docs rather than a single unverifiable case-study number — RLS-indexed columns, N+1 joins, the connection-pooler trap on serverless, image transforms, and caching layers.
Slow Next.js + Supabase apps tend to fail in the same handful of places: unindexed columns that RLS checks on every query, N+1 selects instead of joins, direct (non-pooled) database connections from serverless functions, missing revalidation on data-fetching pages, unoptimized images served at full resolution, and too much client-side JavaScript for content that could render on the server. This post walks through each fix and why it works, grounded in the official Next.js and Supabase documentation rather than a single anecdotal before/after number — your own gains will depend on your query shape, dataset size, and hosting, so measure your own app with Lighthouse and Supabase's Query Performance dashboard before and after each change.
Start by Measuring#
Before optimizing anything, establish a baseline with:
Lighthouse (Chrome DevTools): Performance score, First Contentful Paint (FCP), Largest Contentful Paint (LCP), Total Blocking Time (TBT), and Cumulative Layout Shift (CLS).
Real User Monitoring: page load time, Time to Interactive, and database query time — via Vercel Analytics, Google Analytics, or your own instrumentation.
Supabase Dashboard → Database → Query Performance: the actual query times against your production data, not a local dev copy.
Common root causes, in the order they're worth checking:
- Unoptimized database queries (usually N+1 selects, or missing indexes on RLS-checked columns)
- No caching strategy
- Large JavaScript bundles
- Unoptimized images
- Blocking render paths
- Too many client-side fetches
Let's fix each one.
1. Database Query Optimization#
Problem: N+1 Queries#
The biggest performance killer was N+1 queries. We were fetching posts, then fetching the author for each post individually.
// ❌ Bad: N+1 queries (1 + N database calls)
async function getPosts() {
const { data: posts } = await supabase
.from('posts')
.select('id, title, author_id')
// Fetching author for each post = N queries
const postsWithAuthors = await Promise.all(
posts.map(async (post) => {
const { data: author } = await supabase
.from('users')
.select('name, avatar')
.eq('id', post.author_id)
.single()
return { ...post, author }
})
)
return postsWithAuthors
}Why it hurts: fetching N posts means 1 query for the list plus N further queries (one per author lookup) — the total round-trip time scales linearly with the number of rows, and each round trip pays full network + connection overhead on top of the query itself.
Solution: Use Joins#
// ✅ Good: Single query with join (1 database call)
async function getPosts() {
const { data: posts } = await supabase
.from('posts')
.select(`
id,
title,
author:users(name, avatar)
`)
return posts
}This collapses the N+1 pattern into a single round trip: PostgREST resolves the author:users(...) embed as one SQL join under the hood, so the query count no longer scales with the number of posts.
Add Database Indexes#
We added indexes on frequently queried columns:
-- Index on foreign keys
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_created_at ON posts(created_at DESC);
-- Composite index for common query patterns
CREATE INDEX idx_posts_status_created ON posts(status, created_at DESC);
-- Index for full-text search
CREATE INDEX idx_posts_title_search ON posts USING gin(to_tsvector('english', title));Indexes on foreign keys and on the columns your WHERE/ORDER BY clauses touch let Postgres use an index scan instead of a sequential scan — this matters even more once Row Level Security is enabled, because every RLS policy check runs as an implicit filter on each row. An unindexed column referenced in a policy turns every query against that table into a full scan. Run EXPLAIN ANALYZE on your slow queries in the Supabase SQL editor to confirm which index would actually be used before adding it.
2. Implement Aggressive Caching#
Problem: Fetching Same Data Repeatedly#
Every page load fetched the same data from Supabase, even when it hadn't changed.
Solution: Multi-Layer Caching Strategy#
Layer 1: Next.js Data Cache
// ✅ Cache static data indefinitely
async function getCategories() {
const { data } = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/categories`,
{
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
cache: 'force-cache', // Cache forever
}
)
return data
}
// ✅ Revalidate periodically
async function getPosts() {
const { data } = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/posts`,
{
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
next: { revalidate: 60 }, // Revalidate every 60 seconds
}
)
return data
}Layer 2: React Cache
import { cache } from 'react'
// ✅ Cache within single request
export const getUser = cache(async (userId: string) => {
const { data } = await supabase
.from('users')
.select('*')
.eq('id', userId)
.single()
return data
})
// Now multiple components can call getUser(id) without duplicate queriesLayer 3: CDN Caching (Vercel)
// app/api/posts/route.ts
export async function GET() {
const { data } = await supabase.from('posts').select('*')
return Response.json(data, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120',
},
})
}Each layer trades freshness for fewer round trips to Supabase: the Data Cache avoids re-fetching data that has not changed, react's cache() avoids re-fetching within a single request when multiple components need the same data, and CDN caching (via Cache-Control/stale-while-revalidate on a Route Handler) lets Cloudflare or Vercel's edge serve repeat requests without hitting your origin at all.
3. Optimize JavaScript Bundle Size#
Problem: Large Bundles Slow Down Initial Load#
A large First Load JS delays Time to Interactive because the browser has to download, parse, and execute more JavaScript before the page becomes responsive. Run the build to see your current number:
npm run build
# reports First Load JS per routeSolution: Code Splitting and Dynamic Imports#
Before:
// ❌ Bad: Importing everything upfront
import { Editor } from '@/components/Editor'
import { Chart } from '@/components/Chart'
import { VideoPlayer } from '@/components/VideoPlayer'
export default function Page() {
return (
<div>
<Editor />
<Chart />
<VideoPlayer />
</div>
)
}After:
// ✅ Good: Dynamic imports
import dynamic from 'next/dynamic'
const Editor = dynamic(() => import('@/components/Editor'), {
loading: () => <EditorSkeleton />,
ssr: false, // Don't render on server if not needed
})
const Chart = dynamic(() => import('@/components/Chart'), {
loading: () => <ChartSkeleton />,
})
const VideoPlayer = dynamic(() => import('@/components/VideoPlayer'), {
loading: () => <VideoSkeleton />,
})
export default function Page() {
return (
<div>
<Editor />
<Chart />
<VideoPlayer />
</div>
)
}Remove Unused Dependencies:
# Analyze bundle
npx @next/bundle-analyzer
# Common wins:
# - moment.js → date-fns (much smaller, tree-shakeable)
# - lodash → native Array/Object methods where possible
# - unused UI library components pulled in by a barrel importnext/dynamic with ssr: false keeps heavy, non-critical components (rich text editors, charting libraries, video players) out of the initial bundle entirely — they load only once the browser requests them, instead of blocking the first paint.
4. Image Optimization#
Problem: Unoptimized Images#
We were serving full-resolution images (2MB+) directly from Supabase Storage.
Solution: Next.js Image Component + Supabase Transformations#
Before:
// ❌ Bad: Raw image URL
<img src={`${supabaseUrl}/storage/v1/object/public/images/${imagePath}`} />After:
// ✅ Good: Next.js Image with optimization
import Image from 'next/image'
<Image
src={`${supabaseUrl}/storage/v1/object/public/images/${imagePath}`}
alt="Post image"
width={800}
height={600}
quality={85}
priority={isAboveFold}
placeholder="blur"
blurDataURL={blurDataUrl}
/>Supabase Image Transformations:
// ✅ Transform images on-the-fly
function getOptimizedImageUrl(path: string, width: number) {
return `${supabaseUrl}/storage/v1/render/image/public/${path}?width=${width}&quality=85`
}
// Usage
<Image
src={getOptimizedImageUrl('avatars/user.jpg', 200)}
width={200}
height={200}
alt="User avatar"
/>Lazy Load Below-the-Fold Images:
<Image
src={imageUrl}
alt="Gallery image"
width={400}
height={300}
loading="lazy" // Lazy load
quality={75}
/>Together, next/image's automatic resizing/format negotiation and Supabase's on-the-fly transforms mean the browser never downloads a full-resolution original just to display an 800px-wide card image — this is one of the highest-leverage fixes for LCP specifically, since LCP is usually the largest above-the-fold image finishing its download.
5. Optimize Database Connection Pooling#
Problem: Connection Timeouts#
Serverless functions and edge routes open a new database connection per invocation. Postgres has a hard cap on concurrent connections, so under enough concurrent traffic, direct connections exhaust that limit and new requests start failing with connection timeouts or "too many clients" errors.
Solution: Configure Connection Pooling#
Supabase's own connection pooling guide recommends a server-side pooler ("use one when connections come from serverless or edge functions, or from anything that scales horizontally") — exactly the transaction-mode, port-6543 setup below.
Supabase Dashboard:
- Go to Database → Connection Pooling
- Enable Transaction mode
- Use pooled connection string
In Code:
// ✅ Use pooled connection for serverless
const supabase = createClient(
process.env.SUPABASE_POOLED_URL!, // Use pooled URL
process.env.SUPABASE_SERVICE_ROLE_KEY!
)Routing through the pooler (transaction mode, port 6543) means each serverless invocation borrows a short-lived connection from a shared pool instead of opening its own — this is what keeps concurrent requests from exhausting Postgres's connection limit.
6. Implement Streaming and Suspense#
Problem: Blocking Render#
Without streaming, a single slow query blocks the entire page from sending any HTML to the browser — the user sees a blank screen until every data dependency on the page resolves, even the ones unrelated to what they're looking at first.
Solution: Stream Content Progressively#
// ✅ Stream with Suspense
import { Suspense } from 'react'
async function SlowComponent() {
const data = await slowDatabaseQuery()
return <div>{data}</div>
}
export default function Page() {
return (
<div>
<h1>Fast content renders immediately</h1>
<Suspense fallback={<Skeleton />}>
<SlowComponent />
</Suspense>
</div>
)
}Wrapping the slow part in <Suspense> lets the static shell (the <h1> here) send to the browser immediately, with the fallback UI swapped for the real content as soon as SlowComponent's data resolves — Time to First Byte for the fast parts of the page is no longer held hostage by the slowest query on it.
7. Optimize Realtime Subscriptions#
Problem: Too Many Subscriptions#
Subscribing to event: '*', table: '*' sends every insert/update/delete across the entire schema to every connected client, whether or not that client's UI cares about the change — the browser has to receive and discard messages it will never use.
Solution: Targeted Subscriptions#
Before:
// ❌ Bad: Subscribe to everything
const channel = supabase
.channel('all-changes')
.on('postgres_changes', { event: '*', schema: 'public', table: '*' },
(payload) => console.log(payload)
)
.subscribe()After:
// ✅ Good: Targeted subscriptions
const channel = supabase
.channel('user-posts')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'posts',
filter: `user_id=eq.${userId}`, // Filter on server
}, (payload) => {
// Handle new post
})
.subscribe()Throttle Updates:
import { useCallback, useRef } from 'react'
function useThrottle(callback: Function, delay: number) {
const lastRun = useRef(Date.now())
return useCallback((...args: any[]) => {
const now = Date.now()
if (now - lastRun.current >= delay) {
callback(...args)
lastRun.current = now
}
}, [callback, delay])
}
// Usage
const throttledUpdate = useThrottle((data) => {
updateUI(data)
}, 1000) // Update UI at most once per secondA filter on the subscription pushes the filtering to the database — the client only receives change payloads it actually asked for, and combining that with a client-side throttle prevents rapid bursts of changes from triggering a React re-render on every single message.
8. Prefetch Critical Data#
Problem: Sequential Data Fetching#
Awaiting each query one after another means the total wait time is the sum of every query's latency, even when the queries are independent and could run at the same time.
Solution: Parallel Fetching#
Before:
// ❌ Bad: Sequential (slow)
async function getData() {
const user = await getUser()
const posts = await getPosts(user.id)
const comments = await getComments(posts.map(p => p.id))
return { user, posts, comments }
}After:
// ✅ Good: Parallel (fast)
async function getData() {
const [user, posts, comments] = await Promise.all([
getUser(),
getPosts(),
getComments(),
])
return { user, posts, comments }
}Prefetch on Hover:
'use client'
import { useRouter } from 'next/navigation'
export function PostLink({ href }: { href: string }) {
const router = useRouter()
return (
<a
href={href}
onMouseEnter={() => router.prefetch(href)}
>
View Post
</a>
)
}Promise.all runs independent queries concurrently, so total wait time is bounded by the slowest single query instead of the sum of all of them. router.prefetch() on hover goes further: it starts loading the next page's data before the user even clicks, so the navigation itself feels instant.
9. Optimize Server Components#
Problem: Unnecessary Client Components#
Marking a component 'use client' when it has no interactivity, hooks, or browser APIs still ships its JavaScript to the browser and re-renders it there — for purely presentational output like a post list, that's bytes and hydration work with no benefit.
Solution: Use Server Components by Default#
Before:
// ❌ Bad: Client Component for static content
'use client'
export function PostList({ posts }: { posts: Post[] }) {
return (
<div>
{posts.map(post => (
<div key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</div>
))}
</div>
)
}After:
// ✅ Good: Server Component (no 'use client')
export function PostList({ posts }: { posts: Post[] }) {
return (
<div>
{posts.map(post => (
<div key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</div>
))}
</div>
)
}Rule: Only use 'use client' when you need:
- Event handlers (onClick, onChange)
- React hooks (useState, useEffect)
- Browser APIs (localStorage, window)
Server Components run only on the server and send their rendered output as part of the initial HTML/RSC payload — no component code, no hydration cost for that subtree. This is why Server Components are the App Router default, and 'use client' should be added deliberately, at the leaf where interactivity is actually needed, rather than at the top of a whole page.
10. Monitor and Measure#
Set Up Performance Monitoring#
Vercel Analytics:
// app/layout.tsx
import { Analytics } from '@vercel/analytics/react'
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
)
}Custom Performance Tracking:
// lib/performance.ts
export function trackPerformance(metric: string, value: number) {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('event', 'performance', {
metric,
value: Math.round(value),
})
}
}
// Usage
const start = performance.now()
await fetchData()
trackPerformance('data_fetch_time', performance.now() - start)What to Expect#
These fixes address well-documented, independently verifiable causes of slowness in Next.js + Supabase apps — but the actual magnitude of improvement depends entirely on your app: dataset size, query shapes, current RLS setup, hosting region, and how many of these anti-patterns you have stacked on top of each other. Don't trust a number you can't reproduce (including any specific figure elsewhere on the web, this post included in earlier drafts) — instead, run Lighthouse and check your own Supabase Query Performance dashboard before and after each change, and treat that measurement as the only one that matters for your app.
If you're looking for business-impact numbers like bounce rate or conversion rate, those depend on far more than page speed (design, copy, audience, traffic source) and Google itself only weakly correlates Core Web Vitals with ranking — don't cite generic speed-to-revenue percentages you haven't measured on your own analytics.
Quick Wins Checklist#
Start with these high-impact optimizations:
- Add database indexes on foreign keys
- Use joins instead of N+1 queries
- Implement Next.js data caching
- Use Next.js Image component
- Dynamic import heavy components
- Enable connection pooling
- Use Server Components by default
- Implement Suspense for slow queries
- Prefetch data in parallel
- Monitor with Lighthouse and Analytics
Frequently Asked Questions (FAQ)#
What's the biggest performance bottleneck in Next.js + Supabase apps?#
Database queries are typically the biggest bottleneck, especially N+1 queries. Fetching data in loops creates hundreds of database calls. Use joins to fetch related data in a single query and add indexes on frequently queried columns.
How do I identify slow database queries?#
Use Supabase Dashboard → Database → Query Performance to see slow queries. Look for queries taking >100ms. Also check your application logs for query times and use EXPLAIN ANALYZE in SQL to understand query execution.
Should I cache all database queries?#
No, cache strategically. Cache static data (categories, settings) indefinitely with cache: 'force-cache'. Cache frequently accessed data with revalidation (next: { revalidate: 60 }). Don't cache user-specific or real-time data.
What's the ideal bundle size for a Next.js app?#
Aim for First Load JS under 200KB. Use dynamic imports for heavy components, remove unused dependencies, and prefer smaller alternatives (date-fns over moment.js). Run npm run build to check your bundle size.
How do I optimize images in Supabase Storage?#
Use Next.js Image component with Supabase image transformations: ${supabaseUrl}/storage/v1/render/image/public/${path}?width=800&quality=85. This serves optimized, resized images instead of full-resolution originals.
What's connection pooling and why do I need it?#
Connection pooling reuses database connections instead of creating new ones for each request. This prevents connection limit errors under load. Enable it in Supabase Dashboard → Database → Connection Pooling and use the pooled connection string.
When should I use Server Components vs Client Components?#
Use Server Components by default—they're faster and send less JavaScript. Only use Client Components for interactivity (onClick, useState), browser APIs (localStorage), or React hooks (useEffect). The exact bundle-size reduction depends on how much of your UI is currently unnecessarily marked 'use client' — check your own npm run build output before and after to see the real number for your app.
How do I measure real user performance?#
Use Vercel Analytics or Google Analytics to track Core Web Vitals (LCP, FID, CLS). Monitor Time to First Byte (TTFB), First Contentful Paint (FCP), and page load times. Set up alerts for performance regressions.
What's the fastest way to improve Lighthouse score?#
Quick wins: 1) Use Next.js Image component, 2) Add database indexes, 3) Implement caching, 4) Dynamic import heavy components, 5) Use Server Components. The score gain from these varies by how unoptimized the starting point was — rerun Lighthouse after each change to track your own delta rather than assuming a fixed point gain.
Should I optimize for mobile or desktop first?#
Optimize for mobile first. Mobile users typically have slower connections and less powerful devices. If your app is fast on mobile, it'll be blazing fast on desktop. Test with Chrome DevTools throttling enabled.
How do I reduce database query time?#
Add indexes on foreign keys and WHERE/ORDER BY columns, use joins instead of N+1 queries, fetch only needed columns with select('id, title'), and enable connection pooling. Use EXPLAIN ANALYZE to confirm each change actually shows up in the query plan rather than assuming a fixed percentage improvement.
What's the impact of using too many Client Components?#
Each Client Component adds JavaScript to your bundle, increasing load time and Time to Interactive, and pays hydration cost on every render. The actual size and delay depend on what those components import — a form with a couple of useState calls costs little; a Client Component that imports a large charting or editor library costs much more.
How often should I run performance audits?#
Run Lighthouse audits weekly during development and after every major feature. Set up automated performance monitoring in CI/CD to catch regressions before they reach production.
Can I use Supabase Realtime without hurting performance?#
Yes, but be strategic. Use targeted subscriptions with filters, throttle updates to 1-2 per second, and unsubscribe when components unmount. Avoid subscribing to entire tables—filter on the server.
Conclusion#
Performance optimization is not a one-time task—it's an ongoing process. Start with the biggest bottlenecks (usually database queries and images), then work your way through the list.
The key is to measure, optimize, and measure again. Use Lighthouse, Real User Monitoring, and your Supabase Query Performance dashboard to identify problems, and confirm each fix against your own baseline rather than assuming a generic percentage from a blog post.
What's your biggest performance challenge? Share in the comments!
Further reading: Next.js 15 vs 14: Real Benchmarks + Upgrade Verdict (2026).
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
Continue Reading
Fix next/image Layout Shift (CLS) in Next.js
next/image only prevents layout shift when it can reserve space. Eight fixes for CLS and LCP problems caused by images, each checked against the Next.js and web.dev documentation.
Next.js 15 vs 14: Real Benchmarks + Upgrade Verdict (2026)
What Next.js's own release notes actually say changed between 14 and 15 — caching defaults, async APIs, and Turbopack's real (and still-partial) stability status — plus the breaking changes that hurt on upgrade.
Next.js 15 Caching Explained: Why Data Shows Stale
Upgraded to Next.js 15 and suddenly your data is stale — or refreshing too often? The caching model changed completely. Here is what actually happens and how to control it.


