🙆
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