【調査中】App Router で取得したデータによる Cache-Control の制御ができない?
やりたいこと
fetch したデータに応じて、Response Header の Cache-Control
を切り替える。
例えば記事投稿サービスで、公開範囲が「全員に公開」または「会員のみに公開」の2種類がある記事データがあったとします。
export type Post = {
...
visibility: 'public' | 'users-only';
}
キャッシュ戦略は色々あると思いますが、ここではシンプルに以下を前提とします。
- 経路上に CDN
- 「全員に公開」の記事については30秒間キャッシュさせる
- 「会員のみに公開」の記事についてはキャッシュさせない
また、前提条件ではありませんが自分の気持ちとして、self-host で Next.js を使う前提で、可能な限り Next.js サーバー側にキャッシュを握られたくないというスタンスがあります。
Fastly などの CDN を活用する場合、オリジンとの多段キャッシュが生じてしまい管理が複雑化することを避けたいというのが理由です。
pages での実装
import { getPost } from '@/queries/getPost';
import type { GetServerSideProps, InferGetServerSidePropsType } from 'next';
export const getServerSideProps = (async ({ res, params }) => {
// get path parameter
const id = params?.id;
if (typeof id !== 'string') return { notFound: true };
// fetch data
const post = await getPost(id);
// set Cache-Control header depends on visibility
res.setHeader(
'Cache-Control',
post.visibility === 'users-only'
? 'private, no-store, max-age=0, must-revalidate'
: 'public, max-age=30',
);
return { props: { post } };
}) satisfies GetServerSideProps;
type PageProps = InferGetServerSidePropsType<typeof getServerSideProps>;
export default function Page({ post }: PageProps) {
return <h1>{post.title}</h1>;
}
pages では上記のように、gSSP で取得したデータの visibility
を見て、レスポンスヘッダの Cache-Control
を切り替えるということができました。
'public' の記事ページ
❯ curl -IsS http://localhost:3000/posts/1 | grep cache-control
cache-control: public, max-age=30
'users-only' の記事ページ
❯ curl -IsS http://localhost:3000/posts/2 | grep cache-control
cache-control: private, no-store, max-age=0, must-revalidate
データに応じて確かに Cache-Control
の値が変わっています。
App Router の場合
App Router は、pages のページ単位のリクエストとはモデルが違い、データ取得は基本的に Server Component (以後 SC)に結びついていて、SC 単位で柔軟にキャッシュ制御を行います。
そのため、SC でのデータ取得のキャッシュをレスポンスヘッダの Cache-Control
で制御する、のようなことはできません。
Middleware で対処
SC はコンポーネントレベルですが、Middleware なら、App Router であってもリクエストごとのレスポンスヘッダに触れることができます。
そのため、例えば安易に、記事情報を取得して visibility
によってヘッダーを変える、という処理を Middleware に実装する場合、以下のように書けます。
import { getPost } from './queries/getPost';
import { NextResponse } from 'next/server';
import type { NextMiddleware } from 'next/server';
export const config = {
matcher: '/posts/:path*',
};
const middlewareFn = (async (req) => {
const res = NextResponse.next();
const matches = req.nextUrl.pathname.match(/\/posts\/([^?]+)/);
if (matches === null) throw new Error();
const postId = matches[1];
const post = await getPost(postId);
res.headers.set(
'Cache-Control',
post.visibility === 'users-only'
? 'private, no-store, max-age=0, must-revalidate'
: 'public, max-age=30',
);
return res;
}) satisfies NextMiddleware;
export default middlewareFn;