メインコンテンツへスキップ
← 記事一覧に戻る
·開発·9 min read

Next.js の root layout で Supabase Auth を Server Component にしたら全ページ Edge cache が死んだ話

Next.jsSupabaseVercelキャッシュ個人開発

結論:cookies() を呼ぶ Server Component を root layout に置くと全ページが dynamic になる

3 行サマリー:

  • createClient() + cookies() + supabase.auth.getUser() を Server Component で実行すると Next.js が「ユーザー固有コンテンツ」と判定する
  • root layout に常駐する AuthButton がこれをやっていたため、配下の全ページが cache-control: private, no-cache, no-store になった
  • AuthButton を 35 行 client component 化するだけで全 99 記事が Edge cache 対象に戻った

今すぐ確認するチェックリスト:

  • layout.tsx 直下に async な Server Component があって cookies() を読んでいないか
  • Vercel の build output で記事 / LP ページが (SSR Function)になっていないか
  • cache-control: private が返ってきていないか(curl -I https://your-site.com/articles/xxx

何が起きたか

masatoman.net を Vercel にデプロイして数週間後のことです。

Vercel の Usage ページを開いたら、Fluid Active CPU が 当日分をほぼ使い切っている状態を発見しました。13 プロジェクトを同じ team で管理していますが、消費しているのは masatoman.net のみ。他 12 プロジェクトの合計よりも多い。

「ブログサイトがなぜこんなに CPU を使うのか?」

Vercel のログを確認すると、攻撃 bot(/wp-admin/install.php 系)とクローラーが 1 秒 30 リクエスト近いペースで叩いていました。ただし bot 対策の前に「なぜ静的 HTML を返さずに毎回 SSR しているのか」を先に解決すべきだと気づきました。

curl -I https://www.masatoman.net/articles/stripe-webhook-naked-www-redirect-silent-failure-2026 を打つと:

cache-control: private, no-cache, no-store, max-age=0, must-revalidate

ブログ記事に private, no-store が返っています。これは CDN も Edge も一切キャッシュしない設定です。


根本原因の特定

Next.js の App Router では、cookies() / headers() を呼ぶ Server Component が 1 つでも含まれると、そのルートのレンダリングが dynamic になります。

問題は「root layout に常駐する AuthButton が Server Component で cookies を読んでいた」ことでした。

// 修正前 — src/components/AuthButton.tsx(問題のあるパターン)
// Server Component として動作していた

import { createClient } from '@/lib/supabase/server'

export default async function AuthButton() {
  const supabase = await createClient()         // cookies() を内部で呼ぶ
  const { data: { user } } = await supabase.auth.getUser()

  return user ? (
    <span>ログイン中: {user.email}</span>
  ) : (
    <a href="/login">ログイン</a>
  )
}

createClient() の中で @supabase/ssrcreateServerClientcookies() を呼んでいます。これにより:

root layout (Server)
  └── Header (Server)
       └── AuthButton (Server) ← cookies() を呼ぶ

Next.js のキャッシュ判定:

  • cookies() を含む → 「リクエストごとに異なる可能性」と判断
  • root layout に伝播 → 配下の全ルートが dynamic になる

Build output 確認:

npm run build
Route (app)                              Size     First Load JS
┌ ○ /                                   ...
├ ● /articles/[slug]                    ← ●(SSR Function)に!
├ ● /articles/stripe-webhook-...
...
○  (Static)   prerendered as static content
●  (Dynamic)  server-rendered on demand

99 本の記事ページが全部 になっていました。generateStaticParams で ISR 対象にしていたはずの記事も全滅。


修正:AuthButton を Client Component 化

Server Component で認証状態を取る必要はありません。ログイン状態の確認は client side から Supabase Auth の onAuthStateChangegetSession() で十分です。

// 修正後 — src/components/AuthButton.tsx
'use client'

import { useEffect, useState } from 'react'
import { createBrowserClient } from '@supabase/ssr'
import type { User } from '@supabase/supabase-js'

export default function AuthButton() {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    const supabase = createBrowserClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL!,
      process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
    )

    supabase.auth.getSession().then(({ data: { session } }) => {
      setUser(session?.user ?? null)
      setLoading(false)
    })

    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (_event, session) => {
        setUser(session?.user ?? null)
      }
    )

    return () => subscription.unsubscribe()
  }, [])

  if (loading) return null

  return user ? (
    <span className="text-sm text-gray-600">ログイン中</span>
  ) : (
    <a href="/login" className="text-sm text-blue-600 hover:underline">
      ログイン
    </a>
  )
}

変更点:

  • 'use client' を追加(35 行程度のファイル全体)
  • Server 側の createClient()createBrowserClient() に変更
  • async/awaituseEffect + useState に変更

修正後の確認

Build output:

Route (app)                              Size     First Load JS
┌ ○ /                                   ...
├ ○ /articles/[slug]                    ← ○(Static)に!
├ ○ /articles/stripe-webhook-...
...

全記事ページが (Static)になりました。

本番デプロイ後のレスポンスヘッダー確認:

curl -I https://www.masatoman.net/articles/stripe-webhook-naked-www-redirect-silent-failure-2026
cache-control: public, max-age=0, must-revalidate
age: 249
x-vercel-cache: HIT

age: 249 — 249 秒前にキャッシュされたレスポンスが返っています。x-vercel-cache: HIT で Edge から配信されています。


なぜ気づきにくいか

この問題が厄介なのは、ローカル開発では症状が出ないことです。

npm run dev では Next.js がキャッシュ最適化を一部スキップするため、cache-control: private は出ません。npm run build && npm run start でも確認しにくい。

症状が顕在化するのは:

  1. Vercel にデプロイして CPU 使用量が跳ね上がる
  2. curl -I で本番の cache-control ヘッダーを確認する
  3. npm run build の出力で が増えていることに気づく

3 番が最も早い気づき方です。新しいコンポーネントを追加したときは、必ず build output の ○/● を確認する習慣をつけると防げます。


同じ罠のパターン一覧

よくある「知らずに dynamic を広げるパターン」:

コンポーネント問題対策
AuthButton(Server)cookies() で dynamicClient Component 化
ThemeToggle(Server)cookies() でテーマ読み込みClient Component 化
LocaleSwitcher(Server)headers() で Accept-LanguageClient Component 化
Analytics(Server)headers() で IP 取得Client Component 化 or middleware
Supabase RealTime(Server)WebSocket でレンダリング強制Client Component 化

原則: root layout やその直下 Header/Footer に cookies() / headers() を読む Server Component を置かない

読む必要があるなら Middleware(middleware.ts)で処理して、コンポーネントには最終結果だけを渡す設計にします。


今日やること(3 つ)

  1. build output を確認する: npm run build して のページを全部洗い出す
  2. root layout のコンポーネントを確認する: cookies() / headers() を呼んでいる Server Component がないかチェック
  3. 本番の cache-control を確認する: curl -I https://your-domain.com/articles/xxxprivate が返っていたら要修正

masatoman.net は現在 Phase 0(最初の読者ベース構築中)。Next.js 16 + Supabase + Vercel 構成で運用中。今回の AuthButton 修正を実施し、全 99 記事ページが Edge cache 対象になったことを build output と本番ヘッダーで確認済み。GA4 と Search Console 連携済みでベースライン計測中。


で、どう稼ぐ?

パフォーマンスを改善した記事は SEO で上位表示されやすくなります(Google は Core Web Vitals + TTFB を評価します)。

masatoman.net のようなブログ型個人開発サイトでは:

  • 記事が Edge cache されると: bot / クローラーへの CPU 消費が激減 → Vercel Free プランで継続運用可能
  • 記事ページの TTFB が改善: Google のクロール効率が上がり、インデックス速度が改善
  • インデックス速度が改善: 新記事への有料記事 CTA への流入が増える

今回の修正は「コスト削減」と「SEO 改善」を同時に実現します。無料プランで運用を続けながら有料記事収益を積み上げるための基盤です。

Supabase Auth + Next.js の実装詳細(有料コンテンツゲート・サブスク管理との組み合わせ)は Lab で解説予定です。


masatoman のメルマガ — 毎週月曜の朝に手紙を 1 通

masatoman.net の今週の記事 1 本を、読者目線で深掘りした手紙が毎週月曜 9:00 に届きます。「これ自分のことだ」が見つかる予告編。登録特典に「個人開発の収益化チェックリスト 15 項目」。

masatoman のメルマガ — 毎週月曜の朝に 1 通

masatoman.net で今週公開した記事の中から 1 本を、読者目線で深掘りした手紙が届きます。「自分も同じことやってる」「ここで詰まってた」が見つかる予告編。

Next Step

次に読むならこの導線です

すべての記事を見る
有料で次へ進む¥1,000

【第12回】夜寝てる間に Claude Code が記事を書き上げる構成 — 月 ¥5K で動く全コード

Claude Codeラボ全12話の集大成。Skills/MCP/サブエージェント/Hooks/リモート運用を統合した「自走する Claude 自動化」を、月 ¥5K の実コストで動かす全構成を公開。寝てる間に競合調査・記事下書き・PR まで自動化する 6 層アーキテクチャの完成版。

次の実験記録も追う

Claude Code × 個人開発の実験ログ、失敗、判断変更をまとめて追いたい人向けに、月次でLab Freeを届けます。

masatoman のメルマガ — 毎週月曜の朝に 1 通

masatoman.net で今週公開した記事の中から 1 本を、読者目線で深掘りした手紙が届きます。「自分も同じことやってる」「ここで詰まってた」が見つかる予告編。

この記事が役に立ったらシェア