T-CREATOR

Redis 使い方:Next.js で Cache-Tag と再検証を実装(Edge/Node 両対応)

Redis 使い方:Next.js で Cache-Tag と再検証を実装(Edge/Node 両対応)

Next.js アプリケーションでキャッシュ戦略を構築する際、Redis は強力な選択肢となります。特に、Cache-Tag(キャッシュタグ)を活用することで、関連するキャッシュを一括で無効化できるため、データの整合性を保ちながら高速なレスポンスを実現できるでしょう。

本記事では、Redis を使った Cache-Tag の実装方法と再検証(Revalidation)の仕組みを、Edge Runtime と Node.js Runtime の両方に対応する形で解説します。初心者の方でも実装できるよう、段階的にコード例を示していきますね。

背景

Next.js 13 以降では App Router が導入され、サーバーコンポーネントやデータフェッチングの仕組みが大きく変わりました。

従来の Pages Router では getServerSidePropsgetStaticProps でキャッシュを制御していましたが、App Router では fetch API の拡張や revalidateTagrevalidatePath といった新しい機能が提供されています。これにより、よりきめ細かなキャッシュ管理が可能になりました。

しかし、Next.js の標準キャッシュだけでは、複数のページやコンポーネントにまたがるデータの整合性を保つのが難しい場合があります。例えば、ブログ記事を更新した際に、記事詳細ページだけでなく、記事一覧ページやカテゴリページのキャッシュも同時に無効化したいケースです。

そこで Redis を導入し、Cache-Tag の仕組みを実装することで、関連するキャッシュを効率的に管理できるようになります。

下図は、Next.js アプリケーションと Redis を組み合わせたキャッシュ管理の基本構造を示しています。

mermaidflowchart TB
  user["ユーザー"] -->|リクエスト| nextjs["Next.js App Router"]
  nextjs -->|キャッシュ確認| redis[("Redis<br/>Cache Store")]
  redis -->|キャッシュヒット| nextjs
  redis -->|キャッシュミス| api["Backend API / DB"]
  api -->|データ取得| redis
  redis -->|タグ付きで保存| redis
  nextjs -->|レスポンス| user

  admin["管理者"] -->|更新操作| webhook["Webhook / API"]
  webhook -->|タグで再検証| redis
  redis -->|該当キャッシュ削除| redis

図で理解できる要点

  • Redis がキャッシュストアとして機能し、Next.js からのリクエストを高速化
  • タグを使って関連するキャッシュをグループ化
  • 更新時にタグ指定で一括無効化が可能

課題

Next.js で Redis を活用したキャッシュ管理を実装する際、以下のような課題に直面します。

Edge Runtime と Node.js Runtime の両対応が必要

Next.js では、同じアプリケーション内で Edge Runtime と Node.js Runtime が混在するケースがあります。Edge Runtime は起動が高速で CDN エッジで実行できる一方、利用できる Node.js API に制限があるため、Redis クライアントライブラリの選択が重要になります。

従来の redis パッケージは Node.js 専用で、Edge Runtime では動作しません。そのため、@upstash​/​redis のような HTTP ベースの Redis クライアントを使う必要がありますね。

Cache-Tag の管理と紐付け

Redis 自体には Cache-Tag の概念がありません。そのため、タグとキャッシュキーの紐付けを自分で実装する必要があります。

具体的には、タグごとにキーのセット(Set 型)を保持し、キャッシュ保存時にタグとキーを関連付け、再検証時にはタグから関連キーを取得して一括削除する仕組みが必要です。

再検証タイミングの制御

Next.js の revalidateTagrevalidatePath は便利ですが、これらは Next.js の内部キャッシュのみを対象としています。Redis のキャッシュと Next.js の内部キャッシュを同期させるには、API Route や Server Actions から明示的に Redis のキャッシュを削除する処理を実装しなければなりません。

以下の図は、Cache-Tag 管理の課題を示したものです。

mermaidflowchart LR
  cache1["キャッシュ<br/>posts:1"] -->|タグ| tag1["tag:posts"]
  cache2["キャッシュ<br/>posts:2"] -->|タグ| tag1
  cache3["キャッシュ<br/>posts:list"] -->|タグ| tag1
  cache3 -->|タグ| tag2["tag:list"]

  tag1 -.->|再検証時に<br/>一括削除| cache1
  tag1 -.->|一括削除| cache2
  tag1 -.->|一括削除| cache3

  style tag1 fill:#ffcccc
  style tag2 fill:#ccccff

図で理解できる要点

  • 複数のキャッシュに同じタグを付与することでグループ化
  • 再検証時にタグを指定すると、関連する全キャッシュが削除される
  • 1 つのキャッシュに複数タグを設定することも可能

解決策

上記の課題を解決するため、以下のアプローチで実装を進めます。

Upstash Redis を採用

Upstash Redis は HTTP ベースの Redis サービスで、Edge Runtime と Node.js Runtime の両方で動作します。専用の SDK @upstash​/​redis を使うことで、環境を問わず同じコードでキャッシュ操作が可能になりますね。

また、Serverless 環境に最適化されており、接続プールの管理が不要なため、Next.js との相性も抜群です。

Cache-Tag 管理の実装パターン

Cache-Tag の仕組みを実装するには、以下の 3 つの操作が必要です。

#操作説明
1キャッシュ保存データとタグを Redis に保存し、タグごとにキーを記録
2キャッシュ取得キーからデータを取得
3タグで再検証タグに紐づく全キーを取得し、一括削除

この仕組みをユーティリティ関数として実装することで、アプリケーション全体で再利用できるようになります。

Next.js の revalidateTag と連携

Next.js の revalidateTag は、Server Actions や Route Handlers から呼び出せます。この関数を呼び出すタイミングで、同時に Redis のキャッシュも削除することで、両方のキャッシュを同期できますね。

以下の図は、再検証フローの全体像を示しています。

mermaidsequenceDiagram
  participant Admin as 管理者
  participant API as API Route
  participant NextCache as Next.js キャッシュ
  participant RedisCache as Redis キャッシュ
  participant DB as データベース

  Admin->>API: データ更新リクエスト
  API->>DB: データ更新
  DB-->>API: 更新完了
  API->>NextCache: revalidateTag("posts")
  NextCache-->>API: Next.js キャッシュ削除
  API->>RedisCache: Redis タグで削除
  RedisCache-->>API: Redis キャッシュ削除
  API-->>Admin: 更新成功レスポンス

  Note over NextCache,RedisCache: 両方のキャッシュが<br/>同期して削除される

図で理解できる要点

  • データ更新時に Next.js と Redis の両方のキャッシュを削除
  • revalidateTag で Next.js 内部キャッシュを無効化
  • 同時に Redis のタグ指定削除を実行して同期を保つ

具体例

それでは、実際のコードを段階的に見ていきましょう。

プロジェクトセットアップ

まず、必要なパッケージをインストールします。

bashyarn add @upstash/redis
yarn add -D @types/node

Upstash Redis のアカウントを作成し、以下の環境変数を .env.local に設定してください。

envUPSTASH_REDIS_REST_URL=https://your-redis-url.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-token-here

これで、Redis への接続準備が整いました。

Redis クライアントの初期化

Redis クライアントを初期化するファイルを作成します。

typescript// lib/redis.ts

import { Redis } from '@upstash/redis';

// Redis クライアントのシングルトンインスタンスを作成
// 環境変数から接続情報を取得します
export const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

このファイルでは、Upstash Redis クライアントをインスタンス化しています。環境変数から URL とトークンを読み込むことで、セキュアに接続情報を管理できますね。

Cache-Tag ユーティリティの型定義

次に、Cache-Tag を管理するユーティリティ関数の型を定義します。

typescript// lib/cache-tags.ts

// キャッシュオプションの型定義
export interface CacheOptions {
  // キャッシュの有効期限(秒単位)
  ttl?: number;
  // キャッシュに付与するタグの配列
  tags?: string[];
}

// キャッシュ取得結果の型定義
export interface CacheResult<T> {
  // キャッシュされたデータ(存在しない場合は null)
  data: T | null;
  // キャッシュがヒットしたかどうか
  hit: boolean;
}

型定義により、TypeScript の型チェックが効くようになり、開発時のミスを防げます。

キャッシュ保存関数の実装

タグ付きでキャッシュを保存する関数を実装します。

typescript// lib/cache-tags.ts(続き)

import { redis } from './redis';

/**
 * タグ付きでデータをキャッシュに保存します
 * @param key キャッシュキー
 * @param data 保存するデータ
 * @param options キャッシュオプション(TTL、タグ)
 */
export async function setCache<T>(
  key: string,
  data: T,
  options: CacheOptions = {}
): Promise<void> {
  const { ttl = 3600, tags = [] } = options;

  // データをキャッシュに保存(TTL 付き)
  if (ttl > 0) {
    await redis.set(key, JSON.stringify(data), { ex: ttl });
  } else {
    await redis.set(key, JSON.stringify(data));
  }

  // タグが指定されている場合、タグとキーの紐付けを保存
  if (tags.length > 0) {
    await Promise.all(
      tags.map((tag) => {
        const tagKey = `tag:${tag}`;
        // タグキーに対して、キーを Set に追加
        return redis.sadd(tagKey, key);
      })
    );
  }
}

この関数では、データを JSON 文字列に変換して Redis に保存します。同時に、指定されたタグごとに Set 型のキーを作成し、キャッシュキーを追加していますね。

TTL(Time To Live)を設定することで、一定時間後に自動的にキャッシュが削除される仕組みです。

キャッシュ取得関数の実装

保存したキャッシュを取得する関数を実装します。

typescript// lib/cache-tags.ts(続き)

/**
 * キャッシュからデータを取得します
 * @param key キャッシュキー
 * @returns キャッシュ結果(データとヒット情報)
 */
export async function getCache<T>(
  key: string
): Promise<CacheResult<T>> {
  // Redis からデータを取得
  const cached = await redis.get(key);

  // キャッシュが存在しない場合
  if (cached === null) {
    return { data: null, hit: false };
  }

  // JSON をパースしてデータを返す
  try {
    const data = JSON.parse(cached as string) as T;
    return { data, hit: true };
  } catch (error) {
    // JSON パースエラーの場合はキャッシュミスとして扱う
    console.error('Cache parse error:', error);
    return { data: null, hit: false };
  }
}

この関数は、指定されたキーでキャッシュを取得し、JSON パースしてオブジェクトとして返します。キャッシュがヒットしたかどうかを hit プロパティで判定できるため、デバッグやモニタリングに活用できますね。

タグによる再検証関数の実装

最も重要な、タグによるキャッシュ一括削除の関数を実装します。

typescript// lib/cache-tags.ts(続き)

/**
 * 指定したタグに紐づく全キャッシュを削除します
 * @param tag 削除対象のタグ
 * @returns 削除されたキーの数
 */
export async function revalidateCacheByTag(
  tag: string
): Promise<number> {
  const tagKey = `tag:${tag}`;

  // タグに紐づくキーの一覧を取得
  const keys = await redis.smembers(tagKey);

  // キーが存在しない場合は何もしない
  if (!keys || keys.length === 0) {
    return 0;
  }

  // 全キーを削除
  await redis.del(...keys);

  // タグキー自体も削除
  await redis.del(tagKey);

  return keys.length;
}

この関数は、指定されたタグに紐づく全キーを取得し、一括で削除します。タグキー自体も削除することで、次回のキャッシュ保存時に新しいタグセットが作成されますね。

Server Component でのキャッシュ活用

実際に Server Component でキャッシュを活用する例を見ていきます。

typescript// app/posts/page.tsx

import { getCache, setCache } from '@/lib/cache-tags';

// 記事データの型定義
interface Post {
  id: number;
  title: string;
  content: string;
  createdAt: string;
}

// API から記事一覧を取得する関数
async function fetchPosts(): Promise<Post[]> {
  const res = await fetch('https://api.example.com/posts');
  if (!res.ok) throw new Error('Failed to fetch posts');
  return res.json();
}

export default async function PostsPage() {
  const cacheKey = 'posts:list';

  // キャッシュから取得を試みる
  const cached = await getCache<Post[]>(cacheKey);

  let posts: Post[];

  if (cached.hit) {
    // キャッシュヒット
    posts = cached.data!;
  } else {
    // キャッシュミス:API から取得
    posts = await fetchPosts();

    // キャッシュに保存(TTL: 1時間、タグ: posts, list)
    await setCache(cacheKey, posts, {
      ttl: 3600,
      tags: ['posts', 'list'],
    });
  }

  return (
    <div>
      <h1>記事一覧</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

この例では、記事一覧をキャッシュから取得し、キャッシュミスの場合のみ API を呼び出しています。キャッシュ保存時に postslist の 2 つのタグを付与することで、柔軟な再検証が可能になりますね。

動的ページでのキャッシュ活用

記事詳細ページでも同様にキャッシュを活用できます。

typescript// app/posts/[id]/page.tsx

import { getCache, setCache } from '@/lib/cache-tags';

interface Post {
  id: number;
  title: string;
  content: string;
  createdAt: string;
}

// 記事詳細を取得する関数
async function fetchPost(id: string): Promise<Post> {
  const res = await fetch(
    `https://api.example.com/posts/${id}`
  );
  if (!res.ok) throw new Error('Failed to fetch post');
  return res.json();
}

export default async function PostDetailPage({
  params,
}: {
  params: { id: string };
}) {
  const cacheKey = `posts:${params.id}`;

  // キャッシュから取得
  const cached = await getCache<Post>(cacheKey);

  let post: Post;

  if (cached.hit) {
    post = cached.data!;
  } else {
    // API から取得してキャッシュに保存
    post = await fetchPost(params.id);

    await setCache(cacheKey, post, {
      ttl: 3600,
      tags: ['posts', `post:${params.id}`],
    });
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <time>{post.createdAt}</time>
    </article>
  );
}

ここでは、記事 ID ごとにキャッシュキーを作成し、個別のタグ post:${id} を付与しています。これにより、特定の記事だけを再検証することも、全記事を一括で再検証することも可能になりますね。

API Route での再検証実装

記事を更新する際の API Route を実装します。

typescript// app/api/posts/[id]/route.ts

import { revalidateCacheByTag } from '@/lib/cache-tags';
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

// 記事更新の型定義
interface UpdatePostRequest {
  title?: string;
  content?: string;
}

export async function PATCH(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const body: UpdatePostRequest = await request.json();

    // 外部 API で記事を更新
    const res = await fetch(
      `https://api.example.com/posts/${params.id}`,
      {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      }
    );

    if (!res.ok) {
      throw new Error('Failed to update post');
    }

    const updatedPost = await res.json();

    // Next.js の内部キャッシュを再検証
    revalidateTag('posts');
    revalidateTag(`post:${params.id}`);

    // Redis のキャッシュも再検証
    await revalidateCacheByTag('posts');
    await revalidateCacheByTag(`post:${params.id}`);

    return NextResponse.json({
      success: true,
      data: updatedPost,
    });
  } catch (error) {
    console.error('Post update error:', error);
    return NextResponse.json(
      { success: false, error: 'Failed to update post' },
      { status: 500 }
    );
  }
}

この API Route では、記事更新後に Next.js の revalidateTag と Redis の revalidateCacheByTag の両方を呼び出しています。これにより、Next.js の内部キャッシュと Redis のキャッシュが同期され、常に最新のデータが表示されますね。

Server Actions での再検証

Server Actions を使った再検証の例も見ていきましょう。

typescript// app/actions/posts.ts

'use server';

import { revalidateCacheByTag } from '@/lib/cache-tags';
import { revalidateTag } from 'next/cache';

// 記事削除の Server Action
export async function deletePost(postId: string) {
  try {
    // 外部 API で記事を削除
    const res = await fetch(
      `https://api.example.com/posts/${postId}`,
      {
        method: 'DELETE',
      }
    );

    if (!res.ok) {
      throw new Error('Failed to delete post');
    }

    // Next.js キャッシュを再検証
    revalidateTag('posts');
    revalidateTag('list');
    revalidateTag(`post:${postId}`);

    // Redis キャッシュを再検証
    await revalidateCacheByTag('posts');
    await revalidateCacheByTag('list');
    await revalidateCacheByTag(`post:${postId}`);

    return { success: true };
  } catch (error) {
    console.error('Delete post error:', error);
    return {
      success: false,
      error: 'Failed to delete post',
    };
  }
}

Server Actions からも同様に、Next.js と Redis の両方のキャッシュを再検証できます。Server Actions は 'use server' ディレクティブを付けることで、クライアントコンポーネントから直接呼び出せるため、フォーム送信などで活用できますね。

Edge Runtime 対応の確認

最後に、Edge Runtime でも動作することを確認します。

typescript// app/api/cache-info/route.ts

import { redis } from '@/lib/redis';
import { NextResponse } from 'next/server';

// Edge Runtime を指定
export const runtime = 'edge';

export async function GET() {
  try {
    // Redis からキャッシュキーの一覧を取得
    const keys = await redis.keys('posts:*');

    return NextResponse.json({
      runtime: 'edge',
      cacheKeys: keys,
      count: keys.length,
    });
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to get cache info' },
      { status: 500 }
    );
  }
}

このエンドポイントは Edge Runtime で動作し、Upstash Redis を使ってキャッシュ情報を取得します。export const runtime = 'edge' を指定するだけで、同じコードが Edge 環境で実行されますね。

下表は、実装したキャッシュ管理システムの主要機能をまとめたものです。

#機能説明使用場面
1setCacheデータをタグ付きで保存Server Component でのデータ取得後
2getCacheキャッシュからデータ取得Server Component の最初の処理
3revalidateCacheByTagタグでキャッシュ一括削除API Route / Server Actions でのデータ更新後
4Edge/Node 両対応Upstash Redis で環境非依存全ての Runtime で利用可能

まとめ

本記事では、Next.js で Redis を活用した Cache-Tag と再検証の実装方法を解説しました。

Upstash Redis を採用することで、Edge Runtime と Node.js Runtime の両方で動作するキャッシュシステムを構築できます。Cache-Tag の仕組みを実装することで、関連するキャッシュを効率的に管理し、データの整合性を保ちながら高速なレスポンスを実現できるでしょう。

重要なポイントは以下の通りです。

  • Upstash Redis を使うことで、Edge/Node 両環境で同じコードが動作
  • Cache-Tag をタグキーと Set 型で実装し、関連キャッシュをグループ化
  • 再検証時 は Next.js の revalidateTag と Redis の revalidateCacheByTag を同時実行
  • Server Component でキャッシュを活用し、API 呼び出しを最小化
  • Server Actions / API Route でデータ更新後に両方のキャッシュを削除

この実装パターンを応用すれば、EC サイトの商品キャッシュ、ニュースサイトの記事キャッシュ、SaaS のダッシュボードデータなど、さまざまなユースケースに対応できますね。

ぜひ、あなたのプロジェクトでも Redis を活用したキャッシュ戦略を試してみてください。

関連リンク