Rebounder Tech Blog

Written by the people who actually run these systems in production.

A Missing force-dynamic Froze a Flag Flip Behind Static HTML

公開 読了時間 約2分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

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.

よくある質問

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.

この記事の根拠

  • TypeScriptファイル 1〜20行目コミット fb01ce6
  • TypeScriptファイル 316〜355行目コミット fb01ce6

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。