7 Lessons From Running Next.js + Supabase in Production
Supabase

7 Lessons From Running Next.js + Supabase in Production

Seven recurring patterns from production runs of Next.js + Supabase, and the fixes that prevented the most common failures.

Updated
8 min read
7 Lessons From Running Next.js + Supabase in Production

Photo by Compagnons on Unsplash

Production traffic exposes gaps that never appear at MVP scale. The seven patterns below are the ones that recur across Next.js + Supabase deployments: missing Row Level Security, missing indexes, N+1 queries, misapplied Server Components, missing caching layers, connection pool exhaustion, and unversioned schema migrations. Each one has a documented fix; each fix is verified against the official Supabase documentation and Next.js App Router docs.

1. RLS Policies Are Not Optional (Even in Development)#

Skipping RLS in development is tempting — every query works without policies, auth is the anon key, and it feels like extra work. But the moment RLS is enabled on a populated table, every client query that was not written against a policy returns an empty array or a permission error. This is a feature, not a bug: it surfaces exactly which queries were never authenticated.

Write RLS policies at the same time you create the table. See the canonical guide in RLS policy design patterns.

The pattern:

sql
CREATE TABLE posts (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  title TEXT NOT NULL,
  user_id UUID REFERENCES auth.users(id)
);
 
-- Enable RLS immediately
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
 
-- Write policies now, not later
CREATE POLICY "Users can view own posts"
  ON posts FOR SELECT
  USING (auth.uid() = user_id);

Test with RLS enabled from day one. If it works in development, it works in production.

2. Database Indexes Are Not Premature Optimization#

Postgres does not add indexes automatically. A query that filters or sorts on an unindexed column falls back to a sequential scan, which is fast on a table of a few hundred rows and catastrophic on a table of a few hundred thousand.

The query:

typescript
const { data } = await supabase
  .from('posts')
  .select('*, profiles(*)')
  .eq('published', true)
  .order('created_at', { ascending: false })
  .limit(20)

The fix:

sql
CREATE INDEX posts_published_created_at_idx 
  ON posts(published, created_at DESC) 
  WHERE published = true;

Run EXPLAIN ANALYZE before and after to confirm the planner switches from a Seq Scan to an Index Scan (see the Postgres docs on EXPLAIN).

Add indexes for any column you filter or sort by:

sql
-- Filter columns
CREATE INDEX posts_user_id_idx ON posts(user_id);
CREATE INDEX posts_published_idx ON posts(published);
 
-- Sort columns
CREATE INDEX posts_created_at_idx ON posts(created_at DESC);
 
-- Composite indexes for common queries
CREATE INDEX posts_user_published_idx ON posts(user_id, published);

Indexes are cheap. Slow queries are expensive.

3. N+1 Queries Will Kill Your Performance#

N+1 queries are the silent killer of dashboard pages. The pattern is always the same: a list endpoint returns N items, then the JavaScript fires N follow-up queries (one per item) to fetch a related aggregate. At N=10 the page loads fine; at N=100 it stalls; at N=1,000 it times out.

The problem:

typescript
// ❌ N+1 query hell
const { data: posts } = await supabase
  .from('posts')
  .select('*')
  .eq('user_id', userId)
 
for (const post of posts) {
  const { count: commentCount } = await supabase
    .from('comments')
    .select('*', { count: 'exact', head: true })
    .eq('post_id', post.id)
 
  const { count: likeCount } = await supabase
    .from('likes')
    .select('*', { count: 'exact', head: true })
    .eq('post_id', post.id)
}

The fix:

typescript
// ✅ Single query with joins
const { data: posts } = await supabase
  .from('posts')
  .select(`
    *,
    comments(count),
    likes(count)
  `)
  .eq('user_id', userId)

Use Supabase's embedded join syntax to fetch related rows in a single round-trip. If you find yourself making queries inside a loop, you are doing N+1 — flatten them with a join or a Postgres view.

4. Server Components Are Your Friend (Use Them)#

Server Components reduce client bundle size by moving data fetching and heavy dependencies to the server. On a typical CRUD page with authentication, the client bundle can drop by an order of magnitude because the data-fetching client (and any state libraries) stays out of the browser bundle. See the official Server Components guide for the rendering model.

The problem:

typescript
// ❌ Client Component fetching data
'use client'
 
export default function PostsPage() {
  const [posts, setPosts] = useState([])
 
  useEffect(() => {
    async function fetchPosts() {
      const { data } = await supabase.from('posts').select('*')
      setPosts(data)
    }
    fetchPosts()
  }, [])
 
  return <div>{/* render posts */}</div>
}

The fix:

typescript
// ✅ Server Component
export default async function PostsPage() {
  const supabase = await createClient()
  const { data: posts } = await supabase.from('posts').select('*')
 
  return <PostList posts={posts} />
}

Default to Server Components. Only use Client Components when you need interactivity, browser APIs, or hooks. Fetch data on the server, ship HTML to the client, and let the framework handle the data round-trip.

5. Caching Is Not Optional#

Caching at the edge is the single biggest lever for read-heavy workloads on Next.js. The App Router ships three caching layers by default: the Data Cache, the Full Route Cache, and the Router Cache. A page that bypasses all three — typically because each layer is explicitly opted out — will hit Postgres on every request, even when the underlying rows have not changed.

The problem (no caching layer active):

typescript
// ❌ No opt-in revalidation: page treats every request as fresh
export default async function PostPage({ params }) {
  const { data: post } = await supabase
    .from('posts')
    .select('*')
    .eq('id', params.id)
    .single()
 
  return <div>{post.title}</div>
}

The fix (opt-in time-based revalidation):

typescript
// ✅ With revalidation: cache the query for 1 hour, rebuild in background
export const revalidate = 3600 // 1 hour
 
export default async function PostPage({ params }) {
  const { data: post } = await supabase
    .from('posts')
    .select('*')
    .eq('id', params.id)
    .single()
 
  return <div>{post.title}</div>
}

A sensible default per content type, drawn from the official Next.js caching guide:

  • Blog posts: 1 hour
  • User profiles: 5 minutes
  • Static content: 24 hours
  • Personalized data: No cache

Use revalidatePath() in Server Actions to invalidate the cache when the underlying data changes.

6. Connection Pooling Matters More Than You Think#

Postgres has a hard limit on concurrent connections. Supabase publishes the per-tier ceiling on its pricing page:

  • Free tier: ~60 connections
  • Pro tier: ~200 connections

Naive code that calls createClient() once per request — typically on a serverless route — opens a fresh Postgres connection each time. Once you exceed the tier ceiling, every subsequent request fails with "too many connections". The official fix is Supavisor, the built-in PgBouncer-compatible pooler.

The problem (no pooling):

typescript
// ❌ New connection per request
export default async function handler(req, res) {
  const supabase = createClient() // New connection
  const { data } = await supabase.from('posts').select('*')
  res.json(data)
}

The fix (use the pooler URL):

typescript
// Use pooler URL for serverless
const supabase = createClient(
  process.env.SUPABASE_URL,
  process.env.SUPABASE_ANON_KEY,
  {
    db: {
      schema: 'public',
    },
    global: {
      headers: { 'x-connection-pooler': 'true' },
    },
  }
)

Enable connection pooling from day one. Monitor active connections in the Supabase dashboard. Upgrade the compute tier before hitting the limit, not after.

7. Migrations Are Not Scary (But Skipping Them Is)#

Migrations are version-controlled schema changes. Without them, staging and production schemas drift within hours: someone edits a column in the Supabase Studio without committing the DDL, and the next deploy fails because the local database does not match. The Supabase CLI migrations workflow solves this by writing each change as a timestamped SQL file that lives in the repo and is replayable on any environment.

The pattern:

bash
# Create migration
npx supabase migration new add_posts_table
 
# Write SQL
# supabase/migrations/20260314_add_posts_table.sql
 
# Apply locally
npx supabase db reset
 
# Push to production
npx supabase db push

Every schema change is version controlled. You can recreate your database from scratch, deploy to multiple environments confidently, and audit the schema history in git log -- supabase/migrations/.

Migrations seem like overhead. They are insurance.

The Bottom Line#

Next.js and Supabase scale beautifully. But you need to:

  1. Enable RLS from day one
  2. Add indexes early
  3. Avoid N+1 queries
  4. Use Server Components by default
  5. Cache aggressively
  6. Enable connection pooling
  7. Use migrations for all schema changes

These aren't advanced techniques. They're basics that save you from pain later.

Start with good patterns. Your future self will thank you.

What lessons have you learned scaling Next.js and Supabase? Drop a comment below.

Adjacent Guides#

Frequently Asked Questions

|

Have more questions? Contact us

Written by

Mahdi Br
Mahdi Br

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.