This is the scariest bug class I've run into in Next.js, not because it's exotic, but because it's genuinely easy to introduce without realizing it, and when it happens, the symptom is one user seeing another user's actual data. Not a crash. Not an error. A real privacy leak that looks, from the outside, like the app is just working fine.
How This Actually Happens
Next.js aggressively tries to render pages statically when it can, generating the HTML once and serving that same cached HTML to every visitor, which is fantastic for performance on genuinely static content. The App Router decides whether a page can be static based on whether it detects anything that makes the page dynamic, reading cookies, reading headers, using searchParams in certain ways.
The danger is when a page reads user-specific data through a path Next.js doesn't recognize as a dynamic signal.
// app/dashboard/page.tsx
export default async function DashboardPage() {
const user = await getCurrentUserFromSomewhere(); // how is this actually getting the user?
return <h1>Welcome back, {user.name}</h1>;
}
If getCurrentUserFromSomewhere reads the session correctly through cookies(), Next.js detects that and correctly marks the route as dynamic, rendered fresh per request, never cached. But if that function, or anything it calls, reads user identity from something Next.js doesn't recognize as a dynamic signal, a global variable set elsewhere, a value cached from a previous request, a misconfigured fetch call with cache: 'force-cache' on what should have been user-specific data, the page can get statically generated once, with one specific user's data baked directly into the HTML, and then served identically to every subsequent visitor.
A More Concrete, Realistic Version
// lib/queries/user.ts
export async function getDashboardData(userId: string) {
// Explicitly opted into caching, on data that's actually user-specific
const res = await fetch(`https://api.example.com/dashboard/${userId}`, {
cache: 'force-cache',
});
return res.json();
}
// app/dashboard/page.tsx
import { cookies } from 'next/headers';
export default async function DashboardPage() {
const cookieStore = await cookies();
const userId = cookieStore.get('userId')?.value;
const data = await getDashboardData(userId as string);
return <Dashboard data={data} />;
}
Reading cookies() here does correctly mark the page as dynamic. But the fetch call inside getDashboardData explicitly overrides caching behavior with cache: 'force-cache', and that specific fetch result can get cached and reused across different users' requests to that same route, if the URL happens to be identical or if the caching layer isn't scoped as tightly as the developer assumed. The exact mechanics depend on deployment setup, but the root mistake is the same: explicitly forcing caching on a fetch that returns user-specific data.
Why This Is So Easy to Miss
Locally, testing as yourself, everything looks completely correct. You see your own data every time, because you're the only user testing, and the timing of cache population versus your own requests rarely exposes the overlap. This bug typically surfaces in production, under real concurrent traffic, when User A loads the dashboard first, gets served fresh data, and User B loads it moments later and gets served User A's cached response instead of their own. Nobody sees an error. The page just quietly shows the wrong person's information.
The Actual Rule
Anything genuinely user-specific should never have cache: 'force-cache' applied to it, explicitly or by inheriting a default from a shared fetch wrapper. If a fetch call's result depends on who's asking, the caching behavior needs to reflect that.
// ❌ Force-caching something that varies per user
const res = await fetch(url, { cache: 'force-cache' });
// ✅ Explicitly no caching for user-specific data
const res = await fetch(url, { cache: 'no-store' });
// ✅ Or, if using a direct database query instead of fetch, use React's cache()
// for per-request deduplication only, never a persistent cache like unstable_cache
import { cache } from 'react';
export const getUserData = cache(async (userId: string) => { ... });
no-store explicitly tells Next.js this fetch should never be cached, full stop, fetched fresh every single time regardless of anything else happening around it. For direct database queries, this is exactly why request-scoped cache() (resetting on every new request) is the safe default for anything user-specific, and unstable_cache (persisting across requests) should be reserved specifically for data that's genuinely the same for everyone, never anything scoped to an individual user.
The Checklist
Anything reading user identity should read it from cookies(), headers(), or an authenticated session, explicitly, not from something implicit or shared. This is what lets Next.js correctly detect the route as dynamic in the first place.
Any fetch call returning user-specific data should be cache: 'no-store' or use next: { revalidate: 0 }, never force-cache, and never inherit a default caching config from a shared wrapper without checking.
Any direct database query returning user-specific data should use cache() for per-request deduplication at most, never unstable_cache, which is explicitly meant to persist across different requests and different users.
Test with two different accounts, not just your own, specifically checking whether switching accounts ever shows stale data from the previous one. This is the actual way to catch this in practice, since testing solo as one user almost never surfaces it.
If you're running a Next.js app with any personalized dashboard, genuinely go test it logged in as two different accounts, in quick succession, and watch closely for any data that seems to lag behind or briefly show the wrong account's info. If you've hit this in production before, I'd genuinely like to hear how it actually surfaced, drop it in the comments.
Get the templates: https://pixelanas.gumroad.com
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)