</>CodeWithKarani

revalidate = 0 Isn't Stopping Next.js From Caching Your Supabase Data

Karani GeoffreyKarani Geoffrey7 min read

You added export const revalidate = 0 to the top of your page, redeployed, and the data is still stale. The dashboard shows numbers from the build, not from the database. You edit a row in Supabase, refresh the page in production, and nothing changes. Meanwhile next start on your laptop behaved perfectly, which is the special kind of cruelty this bug reserves for you.

Here is the thesis, and it is going to save you an evening: Next.js caching is not one switch, it is three separate caches, and revalidate = 0 only addresses one of them. When your data comes from an SDK like Supabase, Prisma, or a raw database driver instead of a plain fetch(), the knob you turned is not connected to the cache that is actually holding the stale data. You are turning off the lights in a room you are not standing in.

revalidate = 0 configures Next.js's Data Cache, which only tracks instrumented fetch() calls. Supabase and other SDKs use their own fetch, so what is actually caching your page is the Full Route Cache from static rendering at build time. Force the route to render dynamically instead:

  • Put export const dynamic = 'force-dynamic' at the top of the route segment, or
  • Call await connection() (from next/server) right before your Supabase query, or
  • Read cookies() / headers() in the request path, which the @supabase/ssr client already does when you wire it up correctly.

On Next.js 15 and later, fetch() defaults to uncached, so the Data Cache side of this problem mostly disappears, but the Full Route Cache still statically renders you into stale data unless you opt the route out.

The symptom: revalidate = 0 does nothing to your Supabase data

The code that looks like it should work, and does not:

// app/dashboard/page.tsx
import { createClient } from '@/utils/supabase/server'

export const revalidate = 0 // "please never cache this page"

export default async function Dashboard() {
  const supabase = await createClient()
  const { data: orders } = await supabase
    .from('orders')
    .select('*')
    .order('created_at', { ascending: false })

  return <OrdersTable rows={orders ?? []} />
}

You insert a new order, refresh, and the table does not grow. In development (next dev) it updates every time, so you assume it is a deploy issue. It is not. The page was rendered once, at build time, and Next.js is serving that snapshot. revalidate = 0 did not prevent it, because revalidate speaks to a cache that never saw your Supabase call.

Why this happens: three caches, not one

The App Router has several caching layers. Three of them matter here, and confusing them is the entire bug.

Where your stale data actually lives revalidate = 0 Route segment config. Only tunes the Data Cache. Data Cache Per fetch() call. Supabase never enters here. Full Route Cache The whole rendered page, frozen at build time. Supabase query renders into the page, which is what gets frozen
The knob you turned and the cache holding your data are on opposite sides of the diagram.

1. The Data Cache (per-fetch)

Next.js patches the global fetch(). Every fetch it can see gets a cache entry keyed by URL and options. revalidate and the fetch-level cache option control this cache. The critical word is see: Next.js only instruments its own patched fetch. A database driver that opens a TCP socket, or an SDK that holds its own fetch reference, never touches the Data Cache. Supabase's PostgREST calls do go over HTTP, but the client is built to make requests the framework does not treat as cacheable data fetches, so tuning revalidate against it is a no-op.

2. The Full Route Cache (per-route, at build)

This is the one actually biting you. During next build, Next.js tries to statically render every route it can. If a route has no dynamic signal, it renders once, captures the RSC payload and HTML, and serves that copy to everyone until the next build. Your Supabase query ran a single time, during the build, and the result was baked into the frozen page. No amount of revalidate = 0 matters if the route was declared static, because revalidate = 0 is a Data Cache instruction, not a "render this dynamically" instruction. To be fair to the confusion, this is genuinely non-obvious and the closed GitHub issues about it never spell it out cleanly.

3. The version pivot that changes the whole story

In Next.js 14, fetch() defaulted to force-cache: data was cached unless you said otherwise. In Next.js 15, that default flipped to uncached (no-store), because the implicit caching caused exactly these "why is my data stale" bugs. So on 15+ the Data Cache is far less likely to be your problem. But the Full Route Cache did not change: a route with no dynamic API is still statically rendered at build time. Upgrading to 15 does not fix a page that renders its Supabase data statically. You still have to opt the route out of static rendering.

The fix, in steps

Step 1: Confirm the route is being statically rendered

Run a production build and read the route legend:

npx next build

Look for your route in the output. The marker in front of it tells you everything:

Route (app)                     Size
○ /dashboard                    1.2 kB
●  (SSG)     prerendered as static content
ƒ  (Dynamic) server-rendered on demand
○  (Static)  prerendered as static content

If /dashboard shows ○ (Static), that is your stale page. You want it to show ƒ (Dynamic). This build output is the single most useful diagnostic here, and it is the step most people skip.

Step 2: Force the route to render dynamically

The bluntest, most explicit fix is a route-segment export. Replace the ineffective line:

-export const revalidate = 0
+export const dynamic = 'force-dynamic'

dynamic = 'force-dynamic' opts the segment out of both static rendering (the Full Route Cache) and the Data Cache. The page now runs on every request. This is correct for a live dashboard that must never be stale.

Step 3: Or scope it to the query with connection()

If you would rather keep the option of static rendering elsewhere and only force dynamic behaviour where the data is read, call connection() immediately before the Supabase query. It is the stable replacement for the older unstable_noStore(), and the Next.js docs give a database example for exactly this reason:

import { connection } from 'next/server'
import { createClient } from '@/utils/supabase/server'

export default async function Dashboard() {
  await connection() // prerendering stops here; code below runs per request
  const supabase = await createClient()
  const { data: orders } = await supabase.from('orders').select('*')
  return <OrdersTable rows={orders ?? []} />
}

Anything downstream of connection() is excluded from prerendering, so the Supabase call happens on each request rather than once at build.

Step 4: Better yet, let the Supabase SSR client do it

If you use @supabase/ssr and build the server client through cookies(), reading cookies is itself a request-time API. A route that reads cookies cannot be statically rendered, so it becomes dynamic automatically. If your createClient helper reads cookies() the way the Supabase guide shows, you often do not need any extra directive. The bug tends to appear when someone caches or hoists the client above the request scope, cutting the cookie read out of the render path.

Verification

Rebuild and confirm the marker flipped:

npx next build | grep dashboard

You want to see the dynamic marker, not the static one:

ƒ /dashboard    server-rendered on demand

Then deploy, insert a row in Supabase, and refresh. The new row appears without a rebuild. For a belt-and-braces check, add a timestamp to the page (new Date().toISOString()) and confirm it changes on every reload. If the timestamp changes but your data does not, the staleness is downstream in your own code or a Supabase read replica, not in Next.js.

What people get wrong

"Just set revalidate = 0." This is the top answer everywhere and it is the reason you are here. It is not wrong in general, it is wrong for SDK data, because it targets the Data Cache and your data is frozen in the Full Route Cache. Related knobs like revalidateTag() and the revalidate export being ignored in production fail for the same underlying reason: they only touch caches that saw an instrumented fetch.

"Pass { cache: 'no-store' } to Supabase." The Supabase client does not forward an arbitrary per-call Next.js fetch option to the framework's Data Cache, so this usually does nothing. Control rendering at the route level with dynamic or connection(), not by trying to bolt a fetch option onto the SDK.

"Add a random query string to bust the cache." Cache-busting query strings are for HTTP caches and CDNs. They do not change whether Next.js prerenders a route. You will scatter junk through your app and the page will still be static.

"Disable caching globally so I never deal with this again." Turning the whole app dynamic throws away the static rendering that makes most pages fast and cheap to serve. Opt out precisely, on the routes that genuinely need live data. If you are fighting invalidation more broadly, the same discipline applies at the data layer, which I wrote up in fixing Redis cache invalidation properly.

When it is still broken

  • The build still says Static. Something is caching your client above the request. Make sure createClient is called inside the component or route handler, not at module top level, and that it reads cookies() per request. A module-scoped client is a static-render magnet. See the mechanics in every way Next.js opts a route out of static rendering.
  • Dynamic route, still stale. Check whether a parent layout or a fetch() elsewhere in the tree is caching. Also confirm you are hitting the origin and not a CDN edge cache with its own TTL.
  • Stale only for some users. That is a Supabase read-replica or connection-pool issue, not Next.js. Query the primary or check your replica lag.
  • Works in preview, stale in production only. Confirm the deploy actually rebuilt. A skipped build serves the previous static output regardless of your code.

Frequently asked questions

Why does revalidate = 0 not stop Next.js caching my Supabase data?
Because revalidate = 0 only configures the Data Cache, which tracks Next.js-instrumented fetch() calls. Supabase uses its own fetch, so its data never enters the Data Cache. Your page is frozen by the Full Route Cache from static rendering at build time. Force the route dynamic with export const dynamic = 'force-dynamic' or await connection() before the query.
What is the difference between revalidate and dynamic in Next.js?
revalidate sets how often the Data Cache and cached route revalidate, in seconds. dynamic = 'force-dynamic' opts the route out of static rendering entirely, so it renders on every request and skips both the Full Route Cache and the Data Cache. For live SDK data that must never be stale, dynamic is the correct control, not revalidate.
Does Next.js 15 fix the Supabase caching problem automatically?
Partly. In Next.js 15 the fetch() default changed from force-cache to uncached, so the Data Cache is far less likely to hold stale data. But the Full Route Cache is unchanged: a route with no dynamic API is still statically rendered at build time. You still must opt the route out of static rendering to get live Supabase data.
How do I tell if my Next.js route is statically rendered?
Run npx next build and read the route legend. A circle marker means static (prerendered), and the f marker means dynamic (server-rendered on demand). If your data route shows as static, that build-time snapshot is your stale data. You want it to show as dynamic.
#Next.js#Supabase#Caching#App Router#React
Keep reading

Related articles