🙆

Next.js 15 + Supabase でキャッシュを有効化する方法

に公開

Next.js 15 + Supabase でキャッシュを有効化する方法

はじめに

Next.js 15 と Supabase を組み合わせて開発していると、「Supabaseへのリクエストがキャッシュされない」 という問題に遭遇することがあります。

この記事では、その原因と解決策を詳しく解説し、パフォーマンスとセキュリティを両立する実装方法を紹介します。

問題の発生

Next.js 15 + Supabase の標準的な実装では、以下のような現象が発生します:

  • 同じデータを表示するページでも、毎回 Supabase にリクエストが発生
  • revalidateTag() を呼んでも、Supabase RPC のキャッシュが無効化されない
  • パフォーマンスが期待より低下

原因の調査

Next.js 15 のキャッシュ変更

Next.js 15 では、重要な変更が行われました:

  • fetch requests のデフォルトが cache: 'no-store' に変更
  • Data Cache がオプトインに変更
// Next.js 14 まで(デフォルトでキャッシュ)
const data = await fetch('https://api.example.com/data');

// Next.js 15 から(デフォルトでキャッシュなし)
const data = await fetch('https://api.example.com/data'); // cache: 'no-store'

// キャッシュしたい場合は明示的に指定が必要
const data = await fetch('https://api.example.com/data', { 
  cache: 'force-cache' 
});

@supabase/ssr がキャッシュを無効化する

さらに重要な発見として、@supabase/ssr パッケージが Next.js のキャッシュを自動的に無効化する ことが分かりました。

// ❌ @supabase/ssr - cookiesを使用するため常にdynamic rendering
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export function createClient() {
  const cookieStore = cookies(); // ← これがキャッシュを無効化
  return createServerClient(...)
}

これは、認証が必要なデータフェッチにおいて、ユーザーが自分のデータのみにアクセスできるようにするためのセキュリティ機能です。

Supabase RPC と revalidateTag の非対応

Supabase などのサードパーティライブラリは、Next.js の fetch wrapper を使用しないため:

  • revalidateTag() → fetch API の next.tags にのみ対応
  • supabase.rpc() → 内部的に独自の HTTP クライアントを使用
  • 結果:revalidateTag を呼んでも Supabase RPC には影響しない

解決策:2つのクライアントを使い分ける

アーキテクチャ設計

問題を解決するために、以下の戦略を採用します:

  • @supabase/ssr: 認証が必要な操作(dynamic rendering)
  • @supabase/supabase-js: キャッシュが有効な操作(static rendering)

実装

1. キャッシュ有効なクライアント

// src/lib/supabase/server-cached.ts
import { createClient } from '@supabase/supabase-js';
import { Database } from '@/types/database.types';

// Next.js fetchを使用するcustom fetch関数
export const createFetch = (options: Pick<RequestInit, 'next' | 'cache'>) => 
  (url: RequestInfo | URL, init?: RequestInit) => {
    return fetch(url, {
      ...init,
      ...options,
    });
  };

/**
 * キャッシュ有効なSupabaseクライアント
 * 認証情報は含まれないため、RLSは機能しません
 * 公開データまたは明示的にuser_idを渡すRPC関数で使用
 */
export function createCachedClient() {
  return createClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!, // SERVICE_ROLE_KEYを使用(RLSをバイパス)
    {
      global: {
        fetch: createFetch({
          next: { 
            revalidate: 300, // 5分間キャッシュ
            tags: ['supabase-cached']
          }
        }),
      },
    }
  );
}

// コレクション関連のキャッシュ付きデータ取得関数
export async function getCachedUserCollections(userId: string) {
  const supabase = createCachedClient();
  
  const { data, error } = await supabase.rpc('get_user_collections', {
    p_user_id: userId
  });
  
  if (error) throw new Error(error.message);
  return data || [];
}

2. 認証用クライアント(既存のまま)

// src/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // Server Component からの cookie 設定エラーを無視
          }
        },
      },
    }
  );
}

使い分けパターン

パターン1: Server Component(認証 + キャッシュ済みデータ)

// app/collections/page.tsx
import { createClient } from '@/lib/supabase/server'; // 認証用
import { getCachedUserCollections } from '@/lib/supabase/server-cached'; // キャッシュ用
import { redirect } from 'next/navigation';

export default async function CollectionsPage() {
  // 1. 認証確認(dynamic rendering)
  const authSupabase = await createClient();
  const { data: { user }, error } = await authSupabase.auth.getUser();
  
  if (error || !user) {
    redirect('/login');
  }
  
  // 2. データ取得(cached)
  const collections = await getCachedUserCollections(user.id);
  
  return (
    <div>
      <h1>{user.email}さんのコレクション</h1>
      <div className="grid gap-4">
        {collections.map((collection) => (
          <CollectionCard key={collection.id} collection={collection} />
        ))}
      </div>
    </div>
  );
}

パターン2: Server Actions(認証必須)

// app/actions/collections.ts
"use server";

import { createClient } from '@/lib/supabase/server'; // 認証用のみ
import { revalidateTag } from 'next/cache';

export async function createCollectionAction(formData: FormData) {
  // 認証が必要な操作では従来通りのクライアント使用
  const supabase = await createClient();
  
  const { data: { user }, error: authError } = await supabase.auth.getUser();
  if (authError || !user) {
    throw new Error('認証が必要です');
  }
  
  const name = formData.get('name') as string;
  const description = formData.get('description') as string;
  const isPublic = formData.get('isPublic') === 'true';

  const { data, error } = await supabase.rpc('create_collection', {
    p_name: name,
    p_description: description,
    p_is_public: isPublic
  });

  if (error) throw new Error(error.message);
  
  // キャッシュ無効化
  revalidateTag('supabase-cached');
  
  return data;
}

重要なポイント

セキュリティの考慮

SERVICE_ROLE_KEY を使用する場合、RLS ポリシーがバイパスされるため、RPC 関数内で明示的なセキュリティチェックが必要です:

-- Supabase側でのRPC関数例
CREATE OR REPLACE FUNCTION get_user_collections(p_user_id uuid)
RETURNS TABLE(
    id uuid,
    name text,
    description text,
    is_public boolean,
    created_at timestamptz,
    updated_at timestamptz
)
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
    -- 重要:明示的なセキュリティチェック
    IF p_user_id != auth.uid() THEN
        RAISE EXCEPTION 'アクセス権限がありません';
    END IF;
    
    RETURN QUERY
    SELECT 
        c.id, c.name, c.description, c.is_public, c.created_at, c.updated_at
    FROM collections c
    WHERE c.user_id = p_user_id
    ORDER BY c.updated_at DESC;
END;
$$;

キャッシュ無効化の仕組み

1. ユーザーがページを開く
   → getCachedUserCollections() (キャッシュヒット、高速)

2. ユーザーが新しいコレクションを作成
   → createCollectionAction() (毎回Supabaseに問い合わせ)
   → revalidateTag('supabase-cached') (キャッシュクリア)

3. ページを再読み込み
   → getCachedUserCollections() (キャッシュミス、新しいデータ取得)
   → 次回からまたキャッシュヒット

環境変数の設定

# .env.local
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key  # 新しく追加

まとめ

この実装により、以下のメリットを得られます:

  • パフォーマンス向上: 適切なキャッシュ戦略
  • セキュリティ保持: 認証が必要な操作は動的レンダリング
  • 柔軟性: 用途に応じた使い分けが可能
  • 保守性: 既存コードの変更を最小限に抑制

Next.js 15 と Supabase の組み合わせで、パフォーマンスとセキュリティを両立したアプリケーションを構築できます。

参考資料

Discussion