Next.js の Cache Handler で InvariantError になりハマった
はじめに
Next.js をセルフホストする際、避けて通れないのが Custom Cache Handler の実装です。
Vercel にデプロイすれば勝手にやってくれる ISR や Data Cache の永続化ですが、セルフホスト環境ではデフォルトでメモリキャッシュ(再起動で消える)やファイルシステムキャッシュ(コンテナ間で共有できない)になってしまいます。
これを実装する際に@neshca/cache-handler というライブラリもありますが、repo を見ると分かる通り、メンテが止まっており、プロダクトに取り入れるには少し懸念があります。結局やっていることは、redis にパスなどのキーをもとにしてデータを add/get するだけなのでシンプルだろうと思い、Valkey (Redis)を用いて自前実装を試みました。
すると、「データの型」と「オブジェクトの構造」 という2つの落とし穴に見事にハマりました。
この記事では、InvariantError の原因究明と、GitHub のソースコードに基づいた正しい実装方法(Next v16 対応)を共有します。
遭遇したエラー
以下のドキュメントに沿って、実装を進めました。
すると next.config.ts でカスタムハンドラーを指定し、pnpm start で起動。ページにアクセスした瞬間に以下のエラーで落ちました。
Error [InvariantError]: Invariant: Expected cached value for cache key "..." to be a "FETCH" kind, got undefined instead. This is a bug in Next.js.
ログを確認すると、Redis からはデータが引けている(Cache Hit)し、保存したデータには kind: 'FETCH' が含まれています。なのに Next.js は「undefined だ」と言い張ります。
原因調査:Next.js は何をチェックしているのか?
エラーメッセージを手がかりに、Next.js 本体(vercel/next.js)のソースコードを調査しました。
1. データの「ガワ(Wrapper)」が足りなかった
エラーを投げている箇所を見ると、衝撃の事実が判明しました。
src/server/lib/incremental-cache/index.ts
(※ バージョンによって行数は前後しますが、ロジックは同様です)
// Next.js 内部のロジック
const cacheData = await this.cacheHandler?.get(cacheKey, ctx)
if (ctx.kind === IncrementalCacheKind.FETCH) {
// ... (中略) ...
// ⚠️ ここ!! .value.kind を見ている
if (cacheData.value?.kind !== CachedRouteKind.FETCH) {
throw new InvariantError(
`Expected cached value for cache key ${JSON.stringify(cacheKey)} to be a "FETCH" kind, got ${JSON.stringify(cacheData.value?.kind)} instead.`
)
}
}
私は、Redis から get で取得したデータから value を取り出して(アンラップして)返していました。
// ❌ 間違った実装
const cachedWrapper = await repository.get(key);
return cachedWrapper.value; // 中身だけ返す
しかし Next.js は、メタデータ(lastModified)とデータ(value)がセットになったラッパーオブジェクト を期待していたのです。私が中身だけを返した結果、Next.js は (中身).value.kind を参照しようとし、当然 .value など存在しないため undefined となり、エラーが発生していました。
2. JSONシリアライズで Buffer が壊れていた
もう一つの問題は Buffer です。Next.js の Data Cache (kind: FETCH) の body は、内部的に Buffer 型で扱われることがあります。
しかし、標準の JSON.stringify は Buffer を以下のように変換してしまいます。
{ "type": "Buffer", "data": [104, 101, 108, ...] }
これでは復元時に元のバイナリデータとして認識されません。Next.js は body が文字列(または適切なBuffer)であることを期待しています。
解決策:正しい Custom Cache Handler の実装
解決策は以下の2点です。
- 構造を維持する: get メソッドでは { lastModified, value: { ... } } の構造を崩さずに返す。
- Base64変換: 保存時に Buffer を Base64 文字列に変換し、標準的な JSON として扱えるようにする。
実装コード
Valkey (Redis) をリポジトリとして使う場合の完成形を、ざっと記述します。
1. Repository層 (標準的なJSONを使用)
ここには特別なロジックを持たせず、単純な JSON の保存・取得に徹します。
import Redis from "ioredis";
export class ValkeyCacheRepository {
// ... (Redis接続初期化などは省略) ...
public async get(key: string) {
const data = await this.client.get(key);
return data ? JSON.parse(data) : null;
}
public async set(key: string, data: any, ttl?: number) {
const serialized = JSON.stringify(data);
if (ttl) {
await this.client.setex(key, ttl, serialized);
} else {
await this.client.set(key, serialized);
}
}
}
2. Handler層 (構造の維持と型変換)
ここで Next.js と Redis の間の翻訳を行います。
import { ValkeyCacheRepository } from "./repository";
export default class ValkeyHandler {
async get(cacheKey: string) {
const repository = new ValkeyCacheRepository();
// 1. ラッパーごと取得 ({ lastModified, value: ... })
const cachedWrapper = await repository.get(cacheKey);
if (!cachedWrapper) return undefined;
const storedValue = cachedWrapper.value;
// 2. ROUTE (HTML等) の復元: Base64 -> Stream
if (storedValue.kind === "ROUTE" && storedValue.body) {
const bodyBuffer = Buffer.from(storedValue.body, 'base64');
return {
lastModified: cachedWrapper.lastModified, // ✅ ラッパー構造を維持
value: {
...storedValue,
body: new ReadableStream({
start(controller) {
controller.enqueue(bodyBuffer);
controller.close();
},
}),
}
};
}
// 3. FETCH (APIデータ) の復元
// DeepWikiやOSS調査の結果、bodyはBase64文字列のままでNext.jsが解釈可能
return {
lastModified: cachedWrapper.lastModified, // ✅ ラッパー構造を維持
value: storedValue
};
}
async set(cacheKey: string, pendingEntry: Promise<any>) {
const repository = new ValkeyCacheRepository();
const entry = await pendingEntry;
if (!entry) return;
let valueToSave = entry;
// 4. 保存時の変換: Buffer/Stream -> Base64
if (entry.kind === "ROUTE" && entry.body) {
// Streamを読み切ってBase64化 (省略: getReader()などを使用)
const buffer = await streamToBuffer(entry.body);
valueToSave = { ...entry, body: buffer.toString('base64') };
}
else if (entry.kind === "FETCH" && entry.data?.body) {
// BufferならBase64文字列に変換してJSON化できるようにする
const body = entry.data.body;
valueToSave = {
...entry,
data: {
...entry.data,
body: Buffer.isBuffer(body) ? body.toString('base64') : body
}
};
}
// 5. ラッパー構造を作って保存
await repository.set(
cacheKey,
{
lastModified: Date.now(),
value: valueToSave, // ここに中身を入れる
},
entry.revalidate
);
}
}
まとめ
Next.js の Custom Cache Handler を実装する際は、以下の点に注意が必要です。
- アンラップしない: Next.js は { lastModified, value: { kind: ... } } という入れ子構造を期待しています。良かれと思って .value を剥がすと InvariantError になります。
- Buffer対策: FETCH キャッシュの body は Buffer の場合があります。Redis 等に入れる際は Base64 文字列に変換しないと、復元時に壊れます。
- ソースコードを見る: エラーメッセージの got undefined が「値そのものが undefined」なのか「プロパティアクセスした結果が undefined」なのか、OSSのコードを追うことで特定できました。
セルフホスト勢の助けになれば幸いです。
Discussion