[Cache Components] Direct DB Access (e.g., SQLite) behaves as Static after Build
Link to the code that reproduces this issue
https://codesandbox.io/p/devbox/hopeful-sound-x95mvz?workspaceId=ws_82LDrV1nmXQ5VGzCWXAS6M
To Reproduce
Create a Server Component (DynamicDataComponent.js) which calls an asynchronous server function (getRotationalData).
The function getRotationalData directly accesses and mutates an external resource (specifically, a local database like SQLite) using a database client (e.g., db.query(), db.run()) instead of the native fetch API. The function logic is designed to return dynamic data that changes with every execution (e.g., increments a counter or rotates an item's status, and crucially, performs an UPDATE in the DB).
Place this Server Component inside a root page (e.g., app/page.js).
Ensure the page DOES NOT use export const dynamic = 'force-dynamic', adhering to the current App Router documentation.
Run the production build command: npm run build.
Start the application: npm run start.
Access the page repeatedly in the browser.
Relevant Code Example This is the structure of the relevant files:
app/page.js (Page Component):
import { Suspense } from "react";
import DynamicDataComponent from "./DynamicDataComponent";
// NO export const dynamic = 'force-dynamic'; is used here,
// relying on the default dynamic behavior of Server Components.
export default async function HoyPage() {
return (
<>
<div>Welcome to the dynamic page</div>
<Suspense fallback={<p>Loading data...</p>}>
<DynamicDataComponent />
</Suspense>
</>
);
}
app/DynamicDataComponent.js (Server Component):
import { getRotationalData } from "@/libs/data";
export default async function DynamicDataComponent() {
// This call is expected to run on every request.
const data = await getRotationalData();
return (
<div className="data-display">
<h2>Current Item ID: {data.id}</h2>
<p>Last Used Timestamp: {data.last_use_time}</p>
</div>
);
}
libs/data.js (Server Action / DB Access):
"use server"
// Using a generic database connection object
const db = getDb();
export async function getRotationalData() {
// 1. Query to retrieve the current item based on DB logic/mutation
const currentItem = db.prepare('SELECT * FROM items WHERE status = "active" ORDER BY last_use_time').get();
if (!currentItem) return { Error: "No items available" };
// 2. CRUCIAL STEP: Data mutation (DB write operation)
// This logic should execute on every request, but the result is cached.
db.prepare(`
UPDATE items
SET usage_count = usage_count + 1,
last_use_time = datetime('now')
WHERE id = ?
`).run(currentItem.id);
return currentItem;
}
Current vs. Expected behavior
Current Behavior After running next build and next start, the output of DynamicDataComponent does not change across multiple requests. The DB mutation (UPDATE) inside getRotationalData is executed only once, proving that the result of the async function is cached statically. This happens because Next.js appears to cache the result of asynchronous functions that do not use the native fetch API, even if the surrounding component is dynamic.
Expected Behavior When a Server Component calls an asynchronous function that accesses a database directly (not via fetch) and performs a mutation, Next.js should infer that the resulting data is dynamic and non-cacheable. The expectation is that the function getRotationalData should re-execute on every server request to ensure the latest data (and mutation) logic is applied.
Provide environment information
Operating System:
Platform: win32
Arch: x64
Version: Windows 10 Pro
Available memory (MB): 16327
Available CPU cores: 12
Binaries:
Node: 22.13.1
npm: 10.9.0
Yarn: N/A
pnpm: N/A
Relevant Packages:
next: 16.0.3 // Latest available version is detected (16.0.3).
eslint-config-next: N/A
react: 19.2.0
react-dom: 19.2.0
typescript: 5.9.3
Next.js Config:
output: N/A
Which area(s) are affected? (Select all that apply)
cacheComponents
Which stage(s) are affected? (Select all that apply)
next build (local)
Additional context
The absence of export const dynamic = 'force-dynamic' in app/page.js is intentional. We are relying on the documented default behavior of Server Components in the App Router, which states that pages are dynamic by default. Previously, we would have used this configuration, but removing it highlighted the core issue: the default "dynamic" setting does not prevent caching of non-fetch data access results during next build, contrary to the expectation for an inherently dynamic, mutating data operation.
Hi,
Yes this one is a bit tricky cuz the node:sqlite (or other variants) module does sync operations, it blocks I/O. You can add await a connection to force it to be dynamic:
import { connection } from 'next/server'
export async function getRotationalData() {
await connection();
// rest of function
}
Otherwise, since this is a like a sync file read, the prerendering step can compete it, and adds it to the static shell. In some iteration of the current docs I had this info. I guess we'll have to add it back to the snippet on prerendered content.
Edit: follow up, which client did you use? better-sqlite3?
Thanks for the clarification!
I’m currently using better-sqlite3 with a database file stored locally on the filesystem, so my observations are based specifically on that setup. I haven’t tested this behavior with external database engines like MySQL or PostgreSQL, so I’m not sure whether the same dynamic-rendering requirements apply in those cases.
I initially thought this behavior was a bug because, according to the documentation, with Cache Components all pages in the App Router are dynamic by default. Under that model, I expected the Server Component, and the async database logic inside it, to re-execute on every request without needing any additional configuration. So when I saw that the result was being prerendered and cached during build, it felt inconsistent with how the dynamic behavior is described.
Previously we could explicitly force dynamic rendering with export const dynamic = 'force-dynamic', but with the new Cache Components architecture the documentation suggests that this shouldn't be required anymore.
Because of that, the need to call await connection() to ensure that the route is treated as dynamic was not obvious. Your explanation makes sense, synchronous I/O like SQLite is interpreted as static work during prerendering.
I’d recommend explicitly documenting that if this is the proper way to make a function or route dynamic when accessing a database directly from a Server Component, then calling await connection() is essential to ensure the page isn’t prerendered or cached as static, whether using SQLite or any similar direct DB client.
The documentation is very complete for fetch-based data access, but for full-stack use cases, where accessing the DB directly from Server Components is often the recommended pattern, this nuance can easily be missed.
Adding this clarification for SQLite and other direct DB clients would really help avoid confusion.
Thank you again for the explanation and the great work you are doing!
I’m experiencing the same issue, and the current behavior seems inconsistent with the App Router’s “dynamic by default” documentation.
Summary
When a Server Component calls a custom async function (not using fetch) that directly mutates a database (e.g., SQLite UPDATE), the result is still cached in production (next build + next start). The function only runs once and does not re-execute on subsequent requests, even though the data is inherently dynamic.
Why this is unexpected
- App Router docs say that pages/layouts are dynamic unless marked static.
- However, dynamic functions that access/mutate a DB without fetch are treated as static.
- Mutating DB logic (UPDATE queries, counters, timestamps) should never be cached.
Problem
-
getRotationalData()performs a DB UPDATE on every request. - In production, the UPDATE runs once and the output is cached.
- This leads to stale UI and incorrect behavior for DB-driven server components.
Expected behavior
Next.js should infer that a Server Component is dynamic when:
- An async function accesses a database directly, and
- The function performs mutations (writes).
This behavior matches real-world expectations of server components that rely on database state.
Temporary Workarounds
Using any of these disables caching:
export const dynamic = "force-dynamic";
// or
export const revalidate = 0;
// or inside getRotationalData()
import { unstable_noStore as noStore } from "next/cache";
noStore();
I initially thought this behavior was a bug because, according to the documentation, with Cache Components all pages in the App Router are dynamic by default. Under that model, I expected the Server Component, and the async database logic inside it, to re-execute on every request without needing any additional configuration. So when I saw that the result was being prerendered and cached during build, it felt inconsistent with how the dynamic behavior is described.
That's true though, but it is complemented by automatically prerendered content - and this kind of sync I/O operation is part of that.
Cache Components eliminates these tradeoffs by prerendering routes into a static HTML shell that's immediately sent to the browser, with dynamic content updating the UI as it becomes ready.
We have done an update to the Cache Components Getting Started section, which covers this topic, although not explicitly.
There's a few things I wanted to collect community feedback on, for example
- immediately resolved promises (wrapping sync work in async functions),
- this kind of db access that's synchronous (better-sqlite3 lists this
Easy-to-use synchronous API)
Both of these complete during prerendering and yeah we know what happens. Most other DB clients have to actually make a network request, and avoid blocking I/O, so that your server can process other work while the query resolves.
I'll see what I can do for this specific case.
I’m experiencing the same issue, and the current behavior seems inconsistent with the App Router’s “dynamic by default” documentation.
Please do checkout the current version of https://nextjs.org/docs/app/getting-started/cache-components#automatically-prerendered-content:
We now state: Cache Components eliminates these tradeoffs by prerendering routes into a static HTML shell that's immediately sent to the browser, with dynamic content updating the UI as it becomes ready.
Expected behavior
Next.js should infer that a Server Component is dynamic when:
- An async function accesses a database directly, and
- The function performs mutations (writes).
This behavior matches real-world expectations of server components that rely on database state.
This specific kind of sqlite libraries are basically a set of C bindings that do the call to read/update data, synchronously. When the prerendering step collects a route output, these operations complete, they don't defer to a new task or anything like that. To meet this expectation, Next.js would have to track the specific functions invoked and defined by the library.
Temporary Workarounds
Using any of these disables caching:
export const dynamic = "force-dynamic"; // or export const revalidate = 0; // or inside getRotationalData() import { unstable_noStore as noStore } from "next/cache"; noStore();
The one way, since v15, is to use await connection. And it is even more important to have it in your tool belt in the case of Cache Components.