A Missing force-dynamic Froze a Flag Flip Behind Static HTML
This article may contain affiliate links. Its content is not affected by advertising.
In short
A public route with no dynamic API call is prerendered by Next.js at build time and cached long-term, so changing a value in the database later never reaches the page unless force-dynamic is declared.
Conclusion
A public route with no dynamic API call is prerendered by Next.js at build time and cached long-term by the CDN through s-maxage. Changing a value in the database later never reaches the page unless force-dynamic is declared.
/login was designed to query the database for whether at least one school has shared login enabled and show the teacher login UI accordingly — but with no force-dynamic, the HTML from build time (zero enabled schools) was served frozen.
Symptom
A system admin set a school’s shared password and enabled teacher_login_enabled, and the teacher login UI still did not appear on /login. The database value updated correctly and the admin screens reflected it. Only the public /login stayed stale.
Cause
/login is a Server Component that queries the database on every request for schools with shared login enabled and switches on teacher mode accordingly. But with no force-dynamic, Next.js prerendered this public route at build time like any other static page. No school was enabled at build time, so HTML meaning “no teacher login” was frozen and given a s-maxage=31536000 (one year) long-term cache.
Checking the production response headers showed x-nextjs-prerender:1 and a cache HIT, identifying static prerendering as the cause.
The fix
Add export const dynamic = "force-dynamic" to /login, pinning it to dynamic rendering that queries the database on every request.
export const dynamic = "force-dynamic";
The actions that set and clear a school’s shared password (setSchoolTeacherPasswordAction / clearSchoolTeacherPasswordAction) also call revalidatePath("/login") to make the change appear immediately after enabling.
revalidatePath(`/ops/schools/${schoolId}/edit`);
revalidatePath(`/ops/schools/${schoolId}`);
revalidatePath("/login");
force-dynamic is the fix; revalidatePath is a second layer on top of it. Either one alone would let the symptom return if force-dynamic were accidentally removed later, or if teacher_login_enabled changed through some other path.
Why it went unnoticed
The teacher-login branch on /login reads correctly in review: “show the UI if any school is enabled”. In local development everything renders dynamically on every request, so the branch being frozen into a cache never reproduces.
The symptom only appears in production, through the CDN cache, where build-time HTML keeps being served for a long time. That logic is correct and that logic runs at the right time are two separate things to confirm.
For another Next.js caching story where the middleware allow-list was the cause, see auth middleware redirecting robots.txt to the login page.
Frequently asked questions
Q1Why was only /login prerendered?
/login was a Server Component that made no dynamic API calls. A public route without force-dynamic is prerendered by Next.js at build time. At build time no school had shared login enabled, so HTML without the teacher login UI was frozen in place.
Q2How was the cause identified?
By checking the production response headers: x-nextjs-prerender:1 was present and s-maxage=31536000 (one year) meant long-term caching. The database was updating correctly while only the public page stayed stale, which pointed at the cache layer.
Q3Isn't revalidatePath("/login") enough?
No. The root cause is being statically prerendered for lack of force-dynamic, so revalidatePath alone would let the same symptom return if force-dynamic were later removed or the value changed through another path. force-dynamic is the fix; revalidatePath is a second safety net.
Q4Where is the teacher login UI decided?
The public login page queries the database on every request for whether at least one school has shared login enabled. That logic is written correctly regardless of force-dynamic; only when it ran was frozen by the cache.
What this article is based on
- TypeScript file lines 1-20commit fb01ce6
- TypeScript file lines 316-355commit fb01ce6
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.