Nuxt Contentブログカレンダーで月末日の記事が表示されないバグの原因と修正
症状
2026年1月30日の記事をブログカレンダーに表示しようとしたところ、カレンダー上に記事が出てこない。リスト表示では問題なく表示されている。
さらに調べると、SSR(サーバサイドレンダリング)の初回表示では記事が見えるが、クライアントサイドでハイドレーション後に消えるという挙動だった。
調査の経緯
1. SSRとCSRの差異に気づく
最初はコンテンツデータの取得に問題があるのかと疑った。しかし、ブラウザのページソース(SSRのHTML出力)を確認すると、1月30日の記事は正しく含まれている。クライアントサイドのVueコンポーネントがマウントされた後に記事が消える。
つまり、サーバ側とクライアント側で同じフィルタロジックを通しているにもかかわらず、結果が異なる。
2. Chrome DevTools MCPで詳細調査
Chrome DevTools MCPを使ってクライアント側の状態を確認した。contentArticles のデータを見ると、publishedAt フィールドの値がサーバ側とクライアント側で異なる形式になっていることが判明した。
- サーバ側:
"2026-01-30"(YYYY-MM-DD形式) - クライアント側:
"2026-01-30T00:00:00.000Z"(ISO 8601形式)
Nuxt Contentは内部でSQLiteからデータを取得し、クライアントに渡す過程でDateオブジェクトのシリアライズが行われる。この変換で、publishedAt がISO 8601の完全な文字列に変わっていた。
3. フィルタロジックの問題箇所を特定
問題は apps/web/app/pages/blog/index.vue の記事フィルタ処理にあった。修正前のコードはこうなっていた:
// 修正前のコード(バグあり)
const allArticles = computed(() => {
const content = (contentArticles.value || []) as Article[]
const all = [...content, ...vuePageArticles]
const now = new Date()
const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0)
const currentMonthEndStr = currentMonthEnd.toISOString().split('T')[0]
// currentMonthEndStr = "2026-01-31"
return all
.filter(article => {
if (!article.publishedAt) return false
// ここが問題!
if (article.publishedAt > currentMonthEndStr) return false
return true
})
.sort(/* ... */)
})
原因
問題は article.publishedAt > currentMonthEndStr という文字列比較にあった。
JavaScriptの文字列比較は辞書順(lexicographic order)で行われる。具体的に何が起きていたかを見てみる。
// currentMonthEndStr(月末日)
const currentMonthEndStr = "2026-01-31"
// クライアント側の publishedAt(ISO文字列)
const publishedAt = "2026-01-30T00:00:00.000Z"
// 文字列比較
console.log(publishedAt > currentMonthEndStr)
// => true(!)
なぜ "2026-01-30T00:00:00.000Z" > "2026-01-31" が true になるのか。
文字列の辞書順比較では、先頭から1文字ずつ比較していく。最初の10文字 "2026-01-30" と "2026-01-31" まではほぼ同じだが、11文字目で差が出る:
publishedAtの11文字目:"T"(文字コード 84)currentMonthEndStrの11文字目: なし(文字列が終了)
JavaScriptでは、短い方の文字列が先頭一致している場合、長い方が「大きい」と判定される...と思いきや、実際にはもう少し細かい。
正確には、10文字目まで比較する:
publishedAt[9]="0"("2026-01-30T...")currentMonthEndStr[9]="1"("2026-01-31")
この場合 "0" < "1" なので、1月30日の記事は通常フィルタを通過する。
ではどんなケースで問題が起きるか。月末日そのものに公開された記事で考える:
// 1月31日が月末で、1月31日の記事の場合
const currentMonthEndStr = "2026-01-31"
const publishedAt = "2026-01-31T00:00:00.000Z"
// 10文字目まで完全一致: "2026-01-31" === "2026-01-31"
// 11文字目: "T" vs undefined
// 長い文字列の方が大きいと判定される
console.log("2026-01-31T00:00:00.000Z" > "2026-01-31")
// => true
// 月末日の記事がフィルタで除外される!
つまり、月末日に公開された記事が、その月のカレンダーから消えるというバグだった。1月31日、2月28日(うるう年は29日)、3月31日...全ての月末日で発生しうる。
今回は1月30日の記事を確認していて気づいたが、実際に問題が顕在化するのは月末日ちょうどに公開された記事だった。
なぜSSRでは問題なかったか
SSR時にはNuxt Contentが publishedAt を "2026-01-30" というYAML由来のそのままの文字列で返す。10文字の文字列同士の比較になるので、辞書順比較でも正しく動作する。
クライアントサイドでは、データがJSONシリアライズ/デシリアライズを経る過程でISO 8601形式の完全な文字列に変換される。これが11文字目以降の T00:00:00.000Z を含むようになり、月末日で文字列比較の結果が逆転する。
修正
修正は単純で、publishedAt を比較する前に日付部分(先頭10文字)だけを切り出す処理を追加した。
// 修正後のコード
return all
.filter(article => {
if (!article.publishedAt) return false
// publishedAtはISO文字列("2026-01-30T00:00:00.000Z")の場合があるため、
// 日付部分のみで比較
const publishedDate = String(article.publishedAt).slice(0, 10)
if (publishedDate > currentMonthEndStr) return false
return true
})
.sort(/* ... */)
String(article.publishedAt).slice(0, 10) で "2026-01-31T00:00:00.000Z" から "2026-01-31" を取り出す。これにより、常に10文字同士の比較になるため、月末日でも正しくフィルタされる。
教訓
日付の文字列比較は形式を揃えてから
日付を文字列として比較する場合、比較対象の形式が揃っていることを確認する必要がある。YYYY-MM-DD と YYYY-MM-DDTHH:mm:ss.sssZ を直接比較すると、文字列長の違いで予期しない結果になる。
// 安全な日付文字列比較
const normalize = (dateStr) => String(dateStr).slice(0, 10)
// 両方を正規化してから比較
if (normalize(publishedAt) > normalize(endDate)) {
// ...
}
SSRとCSRでデータ形式が異なる場合がある
Nuxt ContentのようなSSRフレームワークでは、サーバ側とクライアント側でデータのシリアライズ形式が異なることがある。特に日付型は、サーバ側では文字列のまま扱われ、クライアント側ではDateオブジェクトを経由してISO文字列に変換されるケースがある。
データを扱う際は「どちらの環境でも同じ結果になるか」を意識しておくとよい。
月末日のテストケースを忘れない
カレンダーや日付フィルタのテストでは、月末日を含めることが大事だ。月の1日や15日といった中間の日付だけでテストすると、境界値のバグを見逃す。
- 月末日(28日、29日、30日、31日)
- うるう年の2月29日
- 年末の12月31日
こうした境界値でテストしておけば、今回のようなバグを事前に防げる。
まとめ
| 項目 | 内容 |
|---|---|
| 症状 | 月末日の記事がカレンダーに表示されない(CSRのみ) |
| 原因 | ISO文字列 "YYYY-MM-DDT..." と "YYYY-MM-DD" の文字列比較で月末日が除外される |
| 影響範囲 | 全ての月末日(1/31, 2/28, 3/31, ... 12/31) |
| 修正 | publishedAt を .slice(0, 10) で日付部分のみに正規化してから比較 |
| 発見方法 | Chrome DevTools MCPでクライアント側のデータ形式を調査 |