Scaling Next.js + Supabase: 0 to 100K Users Playbook
Scaling Next.js + Supabase from 0 to 100K users: connection pooling, caching layers, read replicas, queue offloading, and cost controls.
Photo by path digital on Unsplash
Scaling is not one problem. It is a sequence of problems that each look different and arrive at predictable traffic levels.
This playbook walks through what actually breaks as a Next.js + Supabase app grows from zero to 100,000 monthly active users. Every section is in the order you hit it. If you skip ahead, you will over-engineer things that would not have mattered for six months.
Pricing figures cited below reflect the live Vercel and Supabase pricing pages at retrieval date and change frequently — always check the current rates before budgeting:
Stage 0: Before You Have Users (Foundation)#
The work you do here is the difference between a rewrite at 10K users and a calm weekend at 100K.
Pick the right Supabase plan#
The free tier is meant for prototyping, not for production. The moment you charge money — even one dollar — you should be on Pro. The differences that matter in production:
- Daily backups (the free tier only has 7-day point-in-time recovery)
- A larger database size ceiling
- No pause after 7 days of inactivity
- A support SLA that actually responds
Pro is a flat per-project monthly fee published on the Supabase pricing page (Source: supabase.com/pricing, retrieved 2026-07-27). The migration cost from Free to Pro once traffic arrives is enormous because of the downtime window. Start on Pro.
Set up pooling from day one#
The single most common mistake is connecting to the database directly from a serverless function. Every invocation opens a new connection, and at 5K requests per minute you will exhaust the pool.
Use the Supabase connection pooler (pgBouncer) in transaction mode for any server-side database call:
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
export function createClient() {
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
// ... cookie config
},
db: {
schema: 'public',
},
}
)
}The @supabase/ssr helpers already route through the pooler URL. If you build your own Postgres client (via pg or Drizzle), use the ?pgbouncer=true connection string and disable prepared statements.
Enable indexes on obvious columns#
Every foreign key column. Every column used in a WHERE clause on a high-traffic query. Every column in an RLS policy condition.
CREATE INDEX idx_posts_user_id ON posts(user_id);
CREATE INDEX idx_posts_published_at ON posts(published_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_posts_org_id ON posts(org_id);Partial indexes (the WHERE deleted_at IS NULL form) keep the index small when only a subset of rows match the predicate. The actual speedup depends on row distribution and query selectivity — run EXPLAIN ANALYZE on representative data before claiming a percentage.
Choose a region close to your audience#
Supabase has regions in US-East, US-West, EU-West, Ap-Southeast, and more. Next.js serverless functions default to US-East (iad1) on Vercel.
Cross-region round-trip latency between major regions is typically 60-100 ms (Source: AWS inter-region latency tables, retrieved 2026-07-27). If your database is in EU-West and your functions are in US-East, every query pays that latency before any work happens. Match them.
The regions field in vercel.json:
{
"regions": ["fra1"]
}Stage 1: 0 to 1,000 Users (Product Shape)#
At this stage the bottleneck is almost never technical. It is product-market fit. But three technical habits will pay off at every later stage.
Ship with ISR from the start#
Every page that renders data from the database should use Incremental Static Regeneration:
// app/blog/[slug]/page.tsx
export const revalidate = 60 // seconds
export default async function Page({ params }) {
const post = await getPost(params.slug)
return <Article post={post} />
}At 1K users this looks like premature optimization. At 10K users it is the thing keeping your database idle.
ISR caches the rendered HTML at Vercel's edge. A request only hits your origin (and the database) once per revalidation window per region.
Separate reads and writes conceptually#
You do not need a read replica yet. But you should write your data layer as if you had one:
// lib/db/reads.ts — uses cached client
// lib/db/writes.ts — uses direct client, invalidates cacheWhen you later split to a replica, the rewrite is a string swap instead of a refactor.
Log everything to a single place#
Pick a log aggregator on day one — Axiom, Logtail, Datadog, or Vercel's built-in logs if you are lean. Put it behind a single log() function.
When error reports start clustering, a single log destination shortens triage dramatically. Centralized logs are cheap insurance.
Stage 2: 1,000 to 10,000 Users (First Real Load)#
This is where scaling becomes a real concern. The symptoms are usually:
- Occasional timeout errors on page loads
- Database CPU spiking during peak hours
- Vercel build times creeping up
- Random 500s from auth endpoints
Diagnose the first bottleneck#
Run this query in the Supabase SQL editor to find your slowest queries:
SELECT
query,
calls,
total_exec_time / 1000 as total_seconds,
mean_exec_time as avg_ms,
rows / GREATEST(calls, 1) as avg_rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;The top 20 queries usually account for 80% of your database time. Optimize those. Ignore everything else.
Index the N+1 queries you did not notice#
Next.js loops plus Supabase queries create silent N+1 patterns:
// DON'T — one query per post
const posts = await supabase.from('posts').select('*')
for (const post of posts.data) {
const author = await supabase.from('profiles').select('*').eq('id', post.user_id).single()
}// DO — one query, join in Postgres
const posts = await supabase
.from('posts')
.select('*, profiles!posts_user_id_fkey(*)')Supabase's embedded select syntax compiles to a SQL join. It is faster and cheaper than the loop version.
Add Postgres connection retry logic#
pgBouncer occasionally drops idle connections. Without retry logic, users see random failures.
// lib/db/withRetry.ts
export async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
for (let i = 0; i < attempts; i++) {
try {
return await fn()
} catch (error) {
const isRetryable =
error.code === 'ECONNRESET' ||
error.code === '57P01' || // admin_shutdown
error.message?.includes('Connection terminated')
if (!isRetryable || i === attempts - 1) throw error
await new Promise(r => setTimeout(r, 50 * Math.pow(2, i)))
}
}
throw new Error('unreachable')
}Wrap every external database call with this. It converts most transient failures into invisible retries.
Cache the expensive reads#
Next.js 15 gives you unstable_cache:
import { unstable_cache } from 'next/cache'
export const getTopPosts = unstable_cache(
async (limit: number) => {
const { data } = await supabase
.from('posts')
.select('id, title, slug, published_at')
.eq('published', true)
.order('views', { ascending: false })
.limit(limit)
return data
},
['top-posts'],
{ revalidate: 300, tags: ['posts'] }
)
// Invalidate on write
import { revalidateTag } from 'next/cache'
revalidateTag('posts')One cached call replaces thousands of identical database queries per hour.
Stage 3: 10,000 to 25,000 Users (Cache Everything)#
Somewhere around 10K active users, your database becomes the bottleneck for real. The fix is always: cache more aggressively before adding capacity.
Add a real edge cache for API responses#
If you have public API routes (pricing page, blog index, anything anonymous), put a CDN-level cache in front:
// app/api/posts/route.ts
export const revalidate = 60
export async function GET() {
const posts = await getTopPosts(20)
return Response.json(posts, {
headers: {
'Cache-Control': 's-maxage=60, stale-while-revalidate=600',
},
})
}The stale-while-revalidate directive means users see slightly stale data while Vercel fetches fresh data in the background. CDN-served responses typically reduce database read load by one to two orders of magnitude, depending on cache hit ratio (Source: Next.js caching docs, retrieved 2026-07-27).
Move session lookups out of the database hot path#
If every request validates a session by querying auth.users, you are doing 100K database queries per day for something that does not change minute-to-minute.
Use getSession() (cached, reads the JWT) for middleware-level checks and getUser() (verifies against the database) only on critical operations:
// middleware.ts — fast path, verify JWT signature only
const { data: { session } } = await supabase.auth.getSession()
// app/api/admin/delete/route.ts — slow path, full verify
const { data: { user } } = await supabase.auth.getUser()This was covered in depth in Supabase Auth + Middleware: The Complete Session Management Guide for Next.js 15. The short version: use getUser() only when security requires it.
Paginate everything#
Any endpoint that returns a list without pagination will eventually return 10,000 rows and kill your response time. Add it now, even if the list is small:
const PAGE_SIZE = 50
const { data, count } = await supabase
.from('posts')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false })
.range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1)The count: 'exact' option does add a second query. If pages can be "infinite scroll" without showing a total, use count: 'estimated' or drop the count entirely.
Offload sends to a queue#
Email, Slack notifications, webhooks, analytics events — none of these should block a user request. At 25K users, a 500ms sendgrid call that runs inline will eventually time out during a spike.
The minimum viable queue on Supabase is a jobs table:
CREATE TABLE jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
kind text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending',
run_at timestamptz NOT NULL DEFAULT now(),
attempts int NOT NULL DEFAULT 0,
last_error text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_jobs_pending ON jobs(run_at) WHERE status = 'pending';A cron job (Vercel cron or Supabase cron) pops the next 10 pending jobs every minute. Failures increment attempts and push run_at forward with exponential backoff.
This is covered in more depth in the Background Jobs and Async Task Patterns with Next.js and Supabase guide.
Stage 4: 25,000 to 50,000 Users (Scaling Writes)#
Writes are harder to scale than reads. You can not cache them. You can not replicate them. You have to make each write cheaper or batch them.
Audit your write amplification#
Open your application and open pg_stat_user_tables:
SELECT
relname as table,
n_tup_ins as inserts,
n_tup_upd as updates,
n_tup_del as deletes,
seq_scan as sequential_scans,
idx_scan as index_scans
FROM pg_stat_user_tables
ORDER BY (n_tup_ins + n_tup_upd + n_tup_del) DESC;Any table with 10x more writes than your active user count has a write amplification bug. Common causes:
- An
UPDATEon every page view (recording "last seen" per session) - A denormalized counter updated inline instead of batched
- Analytics events written directly to a relational table
Move high-write data to a denormalized staging table and batch-flush every minute.
Use triggers instead of application-level denormalization#
If you maintain a post_count on a profiles table, doing it in application code means every post insert issues two queries and two network round-trips. A trigger does it in one:
CREATE OR REPLACE FUNCTION update_post_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE profiles SET post_count = post_count + 1 WHERE id = NEW.user_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE profiles SET post_count = post_count - 1 WHERE id = OLD.user_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER posts_count_trigger
AFTER INSERT OR DELETE ON posts
FOR EACH ROW EXECUTE FUNCTION update_post_count();Triggers are fast, transactional, and free of network overhead. The trade-off is that they are harder to reason about — every trigger should be documented inline.
Move realtime off the primary#
Supabase Realtime uses logical replication from your primary database. At 50K users, an unfiltered realtime subscription can become a meaningful slice of your database CPU — the exact percentage depends on message volume and replication lag.
Options:
- Filter realtime subscriptions strictly — never subscribe to a whole table, always filter by user or org
- Use a separate schema for high-volume realtime data — keeps the logical replication slot smaller
- Move truly high-volume realtime to Pusher or Ably — Supabase realtime is great for product events, not for arcade games
Stage 5: 50,000 to 100,000 Users (Cost Controls)#
At this point performance is largely solved. The new enemy is cost. Without controls, your monthly bill doubles every quarter while user count grows 20%.
Audit egress first#
Egress is data transfer out of the platform: Vercel charges for bytes leaving their edge, Supabase charges for bandwidth out of Storage. Egress rates are published on the live pricing pages:
- Source: vercel.com/pricing, retrieved 2026-07-27 — bandwidth line item
- Source: supabase.com/pricing, retrieved 2026-07-27 — storage egress line item
Run this in Supabase to find the chattiest endpoints:
SELECT
query,
calls,
total_exec_time,
(rows / GREATEST(calls, 1))::int as avg_rows_per_call
FROM pg_stat_statements
WHERE rows > 1000
ORDER BY rows DESC
LIMIT 20;Any query returning more than 100 rows per call is probably paginated incorrectly. Fix those first.
Compress images before storage#
next/image auto-optimizes images on delivery, but if you are uploading user-generated content, the storage size matters too. A 10 MB phone photo uploaded once serves 10 MB from Supabase Storage to next/image, then 10 MB from next/image to your CDN, then a compressed version to users.
Pre-compress on upload:
import { createClient } from '@supabase/supabase-js'
async function uploadImage(file: File) {
const compressed = await compressImage(file, { maxWidth: 2400, quality: 0.85 })
const { data } = await supabase.storage
.from('user-uploads')
.upload(`${userId}/${filename}`, compressed)
return data
}A single browser-side compression pass (using compressorjs or browser-image-compression) typically cuts storage and egress in half or more, with no visible quality loss. The exact ratio depends on the source format and target width.
Cap the free tier of your product#
Free users cost money. When a large majority of your user base is on a free plan, you have many cost centers and few revenue centers.
Add hard limits on the free plan:
- 100 API calls per day
- 1 GB storage
- 10 team members
If a free user needs more, they convert to paid. If they do not convert, their growth is capped. Either outcome is better than an unconstrained free user consuming infrastructure they do not pay for.
Move cold data to cheaper storage#
Logs, analytics, old webhook payloads — none of these need to live in your primary Postgres. Postgres storage rates are published on the Supabase pricing page; cold storage alternatives (Supabase Storage, BigQuery, Athena, S3 + Glacier) are typically an order of magnitude cheaper per GB-month for data you do not actively query.
Options:
- Partition tables by date — drop old partitions instead of deleting rows
- Archive to Supabase Storage as parquet — queryable via DuckDB for ad-hoc analysis
- Ship to BigQuery or Athena — for real analytical workloads, the per-query price can be one to two orders of magnitude lower than Postgres on the same data set
Pricing for each storage tier is published on the respective provider's pricing page — always check the current rates before budgeting a multi-year cold-storage plan.
Architectural Patterns You Will Need#
The Two-Tier Cache#
At 50K+ users, one cache is not enough:
- Edge cache (Vercel CDN) — for HTML and public API responses, 60-600s TTL
- Application cache (
unstable_cacheor Upstash) — for expensive queries, 30-300s TTL
Requests hit the edge first. If they bypass the edge (authenticated users, fresh data), they hit the application cache. Only then do they touch the database.
The Read Replica Split#
On Supabase Team plan and above, you can add a read replica. Use it for:
- Analytics queries (long-running, read-only)
- Background job reads (batch processing)
- Full-text search (can lag slightly)
Keep on the primary:
- User-facing reads where consistency matters
- Writes (obviously)
- Session lookups
// lib/supabase/replica.ts
export const replicaClient = createClient(
process.env.SUPABASE_REPLICA_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)The Job Queue#
Everything that is not a user-facing request should go through a queue:
- Emails
- Webhooks
- Analytics events
- Third-party API calls
- Long-running computations
- Scheduled tasks
Your user-facing endpoints stay fast because they are only responsible for enqueueing. The queue worker can fail, retry, and backoff without the user noticing.
The Feature Flag Gate#
At 100K users, any deploy that touches a hot path is a risk. Feature flags let you roll out gradually:
import { getFlag } from '@/lib/flags'
export default async function Page() {
const useNewAlgorithm = await getFlag('new-ranking-algorithm')
const posts = useNewAlgorithm
? await getPostsV2()
: await getPostsV1()
return <PostList posts={posts} />
}Start at 1%. Watch error rates. Go to 10%, then 50%, then 100%. A regression at 1% affects 1,000 users, not 100,000.
The Cost Curve#
There is no single canonical cost curve for "Next.js + Supabase at scale" — every app's bill depends on its traffic pattern, feature set, data sizes, and choice of ancillary services. The two dominant line items are Vercel bandwidth/function compute and Supabase database/Storage egress. Both are published on their respective pricing pages and change frequently — check the live numbers before budgeting:
Cost drivers that show up repeatedly in community reports, with their published-rate reference:
| Resource | What drives it | Reference (live pricing pages) |
|---|---|---|
| Vercel bandwidth | Data transfer out at the edge; image-heavy apps are most exposed | vercel.com/pricing |
| Vercel function compute | Execution time of API routes and Server Components | vercel.com/pricing |
| Supabase database size | Storage on the primary; compute add-on tier | supabase.com/pricing |
| Supabase Storage egress | Bandwidth out of Storage for user uploads | supabase.com/pricing |
| Supabase Realtime | Concurrent connections and message volume | supabase.com/pricing |
"Other" line items for most apps include email (Resend), logs (Axiom or Datadog), error tracking (Sentry), analytics (Plausible), and KV cache (Upstash or Vercel KV). Each publishes its own pricing page.
Apps with heavier media or realtime workloads — chat, video, collaborative editing — spend proportionally more on bandwidth and compute than static-content apps do. App shape matters more than user count when projecting cost.
What to Do When You Hit a Wall#
Not every scaling problem has a fix in this playbook. When you hit a wall:
- Reproduce on a smaller scale first. If you can not cause the bug on a 1K-user staging environment, you do not understand it yet.
- Use
EXPLAIN ANALYZEbefore you believe any theory. The query plan is ground truth. Your mental model is a guess. - Do not premature-optimize with a rewrite. A rewrite at 100K users is a multi-month risk. A tactical fix is a multi-day risk.
- Open a Supabase support ticket on Pro+. Slow-query review is one of the deliverables of paid support tiers — check the current SLA on the Supabase pricing page for what your tier includes.
- Profile in production, not staging. Staging load is never realistic. Enable
pg_stat_statementson prod and just look.
Further Reading#
- Next.js + Supabase Performance Optimization: From Slow to Lightning Fast — specific Next.js + Supabase perf wins
- Advanced Caching Strategies for Next.js and Supabase Applications — deep dive into the caching layers
- Database Design and Optimization for Next.js and Supabase Applications — Postgres schema patterns for scale
- Supabase Connection Pooling with PgBouncer on Vercel Serverless — pgBouncer specifics
- Background Jobs and Async Task Patterns with Next.js and Supabase — the queue pattern in detail
- The Complete Next.js + Supabase Production Launch Checklist (47 Items) — the pre-flight checklist for launch
Closing Thoughts#
There is no single scaling trick. There is a sequence of small, incremental fixes, each applied at the right time.
The teams that succeed at scaling are not the ones who over-engineer early. They are the ones who ship fast, measure ruthlessly, and fix bottlenecks one at a time in the order they appear.
Save this playbook. Come back to it at each traffic tier. The problems at each stage are surprisingly consistent, and the fixes are almost always already in this guide.
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Related Guides
Caching Strategies for Next.js + Supabase Applications
Caching patterns for Next.js + Supabase at scale: Redis integration, ISR optimization, SWR patterns, and cache invalidation.
Next.js 15 Partial Prerendering: Guide
Next.js 15 Partial Prerendering: the static shell / dynamic holes model, Suspense boundaries, streaming, caching, and migration paths.
Next.js Performance Optimization for Indie Developers
Ship a fast Next.js app: Core Web Vitals, image and font optimization, bundle trimming, caching, and the RUM checks that catch regressions.