こんにちは。トップ画像はこの前訪れた常照皇寺です。とてもよかった。
そして久々の技術記事な気がします。
Next.js 16で、PPR(Partial Pre-rendering)を実装する手段として Cache Components が導入されました。
私自身、ふだんはプリセールスとして動くことも多く、商談ではSSRやSSG、CSRといった用語はまだまだ使います。
が、Next.jsの世界観では、少なくともページ単位でそうした用語を使うことは過去のものとなりつつあります。
React2Shellの脆弱性が見つかったタイミングで間が悪いですが、自分の理解を深めるために記事としてメモをまとめておきます。
各キャッシュの挙動をおさらい
Next.jsにはRequest Memoization、Data Cache、Full Route Cache、Router Cacheの4つのキャッシュがあります。
うち、レンダリング手法に深く関係してくるData CacheとFull Route Cacheについておさらいしていきます。
以下が利用するプロジェクトの app 以下の構成です。microCMSからデータを取得して表示する簡単なプロジェクトです。
app
├── _components
│ └── posts.tsx
├── _lib
│ └── getPosts.ts
├── layout.tsx
└── page.tsx app
├── _components
│ └── posts.tsx
├── _lib
│ └── getPosts.ts
├── layout.tsx
└── page.tsx Full Route Cacheが有効な場合
まずは2つのキャッシュを利用します。 fetch の next オプションに revalidate: false を指定し、無期限でキャッシュを保持します。
export const getPosts = async () => {
console.log("getPosts is called");
const url = "https://xxx.microcms.io/api/v1/posts";
try {
const response = await fetch(url, {
headers: {
"X-MICROCMS-API-KEY": "xxx"
},
next: {
"revalidate": false,
},
});
if (!response.ok) {
throw new Error("Failed to get posts");
}
const result = await response.json();
return result;
} catch (error) {
console.error("Error getting posts:", error);
throw error;
}
} export const getPosts = async () => {
console.log("getPosts is called");
const url = "https://xxx.microcms.io/api/v1/posts";
try {
const response = await fetch(url, {
headers: {
"X-MICROCMS-API-KEY": "xxx"
},
next: {
"revalidate": false,
},
});
if (!response.ok) {
throw new Error("Failed to get posts");
}
const result = await response.json();
return result;
} catch (error) {
console.error("Error getting posts:", error);
throw error;
}
} 上記の getPosts をServer Component内で実行し、データを取得します。
import { getPosts } from "../_lib/getPosts";
const Posts = async () => {
console.log("Posts Component is rendered");
const { contents } = await getPosts();
return (
<ul>
{
contents.map((post: any) => (
<li key={post.id}>
<h2>{post.title}</h2>
</li>
))
}
</ul>
);
}
export default Posts; import { getPosts } from "../_lib/getPosts";
const Posts = async () => {
console.log("Posts Component is rendered");
const { contents } = await getPosts();
return (
<ul>
{
contents.map((post: any) => (
<li key={post.id}>
<h2>{post.title}</h2>
</li>
))
}
</ul>
);
}
export default Posts; import Posts from "./_components/posts";
export default function Home() {
return (
<main>
<h1>Posts</h1>
<Posts />
</main>
);
} import Posts from "./_components/posts";
export default function Home() {
return (
<main>
<h1>Posts</h1>
<Posts />
</main>
);
} ビルド結果を確認すると、ルートページはStatic Renderingの対象となっています。
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content ルートページにmicroCMSのデータ以外に動的な要素はなく、かつ対象データを取得する fetch のData Cacheが有効になっているので、Full Route Cacheも有効になります。
キャッシュは無期限で保持されるため、SSGと同様に事前ビルドしたページを表示するのみの挙動になります。
revalidate オプションに秒数を指定すれば、Time-based revalidationが有効になります。
Route (app) Revalidate Expire
┌ ○ / 1m 1y
└ ○ /_not-found
○ (Static) prerendered as static content Route (app) Revalidate Expire
┌ ○ / 1m 1y
└ ○ /_not-found
○ (Static) prerendered as static content 一定期間でキャッシュを破棄し、アクセス時に再度データを取得して再レンダリングするISRと同様の挙動になります。
上記のようにStatic Renderingの対象となるページ(Full Route Cacheが有効なページ)では、 .next/server/app/index.html が事前ビルドされます。
中身を見ると、microCMSから取得したコンテンツが埋め込まれているのがわかります。
<body class="geist_a71539c9-module__T19VSG__variable geist_mono_8d43a2aa-module__8Li5zG__variable antialiased">
<div hidden=""><!--$--><!--/$--></div>
<main>
<h1>Posts</h1>
<ul>
<li>
<h2>テスト投稿</h2>
</li>
<li>
<h2>テスト投稿</h2>
</li>
<li>
<h2>テスト投稿</h2>
</li>
</ul>
</main>
いろんなscriptタグたち
</body> <body class="geist_a71539c9-module__T19VSG__variable geist_mono_8d43a2aa-module__8Li5zG__variable antialiased">
<div hidden=""><!--$--><!--/$--></div>
<main>
<h1>Posts</h1>
<ul>
<li>
<h2>テスト投稿</h2>
</li>
<li>
<h2>テスト投稿</h2>
</li>
<li>
<h2>テスト投稿</h2>
</li>
</ul>
</main>
いろんなscriptタグたち
</body> なお、HTMLと同時にRSC Payloadもキャッシュされていて、HydrationもキャッシュされたRSC Payloadを用いて行われます。
Data Cacheのみ有効な場合
次にData Cacheのみ使ってみることにします。
ページがDynamic Re-renderingされるように、 posts.tsx のRoute Segment Configとして revalidate=0 を設定します。
また、Data Cacheによるキャッシュの挙動をコンソール上でつかみやすくするため、 getPosts の戻り値そのものをキャッシュするために unstable_cache を使用します。
import { unstable_cache } from "next/cache";
import { getPosts } from "../_lib/getPosts";
export const revalidate = 0;
const getCachedPosts = unstable_cache(
getPosts,
['posts'],
{
tags: ['posts'],
revalidate: 60,
}
);
const Posts = async () => { import { unstable_cache } from "next/cache";
import { getPosts } from "../_lib/getPosts";
export const revalidate = 0;
const getCachedPosts = unstable_cache(
getPosts,
['posts'],
{
tags: ['posts'],
revalidate: 60,
}
);
const Posts = async () => { ビルド結果は以下のように変わり、ルートページがDynamic Renderingの対象となりました。
Route (app)
┌ ƒ /
└ ○ /_not-found
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand Route (app)
┌ ƒ /
└ ○ /_not-found
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand Dynamic Renderingの対象ページとなったことで、Full Route Cacheは利用されなくなります。
ただ、 getPosts の結果は unstable_cache によってData Cacheにキャッシュされるので、コンソールにはアクセスごとに以下のように表示されます。
getPosts is called
The component is re-rendered
The component is re-rendered
The component is re-rendered
getPosts is called // 60秒経つとキャッシュがrevalidateされる
The component is re-rendered getPosts is called
The component is re-rendered
The component is re-rendered
The component is re-rendered
getPosts is called // 60秒経つとキャッシュがrevalidateされる
The component is re-rendered このように、ページ単位ではSSRと同様の挙動となりますが、データ取得にはキャッシュが用いられるため、一定期間はデータ取得を省略できます。
今回はコンソールの表示で getPosts is called の表示を抑制するために unstable_cache を利用しましたが、通常であれば fetch のオプションに revalidate を指定すればOKです。
最後に、Data Cacheも無効にしてみましょう。
fetch のオプションに revalidate: 0 を指定して、Data Cacheを無効にします。
export const getPosts = async () => {
console.log("getPosts is called");
const url = "https://xxx.microcms.io/api/v1/posts";
try {
const response = await fetch(url, {
headers: {
"X-MICROCMS-API-KEY": "xxx"
},
next: {
revalidate: 0,
},
}); export const getPosts = async () => {
console.log("getPosts is called");
const url = "https://xxx.microcms.io/api/v1/posts";
try {
const response = await fetch(url, {
headers: {
"X-MICROCMS-API-KEY": "xxx"
},
next: {
revalidate: 0,
},
}); この状態ではFull Route CacheもData Cacheも利用されません。
コンソールを見ると、アクセスのたびにgetPostsが実行され、Postsコンポーネントがレンダリングされています。
getPosts Function is called
Posts Component is rendered
getPosts Function is called
Posts Component is rendered
getPosts Function is called
Posts Component is rendered getPosts Function is called
Posts Component is rendered
getPosts Function is called
Posts Component is rendered
getPosts Function is called
Posts Component is rendered 基本的にはアクセスのたびにページがレンダリングされるとともにデータ取得も行われる、SSRと同様の挙動となります。
ここまででおさらい終了。
Cache ComponentsでPPRする
PPR
おさらいの中でわかることは、Dynamic Renderingされるページの場合、HTMLは事前ビルドされず、Data Cacheのみが使われる点。
動的な部分はmicroCMSからデータ取得する箇所だけなのに、そこに引っ張られてページ全体がDynamic Renderingされてしまいます。
これを解決するのがPPRで、PPRでは、静的な部分のみを事前ビルドし、動的な部分は後からブラウザにデリバリーすることができます。
実際に挙動を見ていきます。
PPRはNext.js 16でもOpt-Inな機能なので、 next.config.ts に cacheComponents オプションを追記し有効化します。
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true
};
export default nextConfig; import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true
};
export default nextConfig; page.tsx に少し細工をし、 Posts コンポーネントを Suspense でラップします。
import { Suspense } from "react";
import Posts from "./_components/posts";
export default function Home() {
return (
<main>
<h1>Posts</h1>
<Suspense fallback={<p>Loading...</p>}>
<Posts />
</Suspense>
</main>
);
} import { Suspense } from "react";
import Posts from "./_components/posts";
export default function Home() {
return (
<main>
<h1>Posts</h1>
<Suspense fallback={<p>Loading...</p>}>
<Posts />
</Suspense>
</main>
);
} この状態でビルドすると、結果は以下のようになります。
Route (app)
┌ ◐ /
└ ○ /_not-found
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content Route (app)
┌ ◐ /
└ ○ /_not-found
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content 「Partial Prerender」とあるように、PPRされていることがわかります。
Dynamic Renderingされていた時とは異なり、 .next/server/app/index.html (Static Shell)もビルド時に生成されるようになります。中身はこんな感じ。
<body class="geist_a71539c9-module__T19VSG__variable geist_mono_8d43a2aa-module__8Li5zG__variable antialiased">
<div hidden=""><!--$--><!--/$--></div><!--&-->
<main>
<h1>Posts</h1><!--$?--><template id="B:0"></template>
<p>Loading...</p><!--/$-->
</main><!--$--><!--/$--><!--/&--> <body class="geist_a71539c9-module__T19VSG__variable geist_mono_8d43a2aa-module__8Li5zG__variable antialiased">
<div hidden=""><!--$--><!--/$--></div><!--&-->
<main>
<h1>Posts</h1><!--$?--><template id="B:0"></template>
<p>Loading...</p><!--/$-->
</main><!--$--><!--/$--><!--/&--> このように、 Suspense で囲った部分は <template id="B:0"> と <p>Loading...</p> となっており、
サーバーがデータを取得でき次第RSC Payloadがデリバリーされ、表示が置き換わることになります。
Cache Components
ここまで説明してきたPPR自体はNext.js 15でも利用できますが、16からはCache ComponentsによってAPIが整理されました。
具体的には、 use cache ディレクティブの導入です。
ここまで、Data Cacheを利用するために fetch にオプションを指定したり、Route Segment単位で revalidate を指定したりと、
Next.jsのキャッシュ設計には、複数のAPIを理解する必要がありました。
Cache Componentsでは use cache ディレクティブを指定することで、より簡潔にキャッシュを利用できるようになりました。
利用方法は簡単で、キャッシュしたいページやコンポーネント、関数に use cache ディレクティブを記述するだけです。
ここまで fetch やRoute Segmentごとに指定していたキャッシュのオプションを削除し、 posts.tsx に use cache を指定します。
"use cache";
import { getPosts } from "../_lib/getPosts";
const Posts = async () => {
console.log("Posts Component is rendered");
const { contents } = await getPosts(); "use cache";
import { getPosts } from "../_lib/getPosts";
const Posts = async () => {
console.log("Posts Component is rendered");
const { contents } = await getPosts(); この状態でビルドすると、結果は以下のようになります。
Route (app) Revalidate Expire
┌ ○ / 15m 1y
└ ○ /_not-found
○ (Static) prerendered as static content Route (app) Revalidate Expire
┌ ○ / 15m 1y
└ ○ /_not-found
○ (Static) prerendered as static content Posts コンポーネントはCache Componentsとして扱われ、ルートページはPPRではなくStatic Renderingされるようになりました。
use cache ディレクティブを使用すると、デフォルトで15分間のサーバーサイドrevalidationが設定されます。コンポーネントがキャッシュされるので厳密には異なりますが、Next.js 15以前であれば fetch のオプションに revalidate: 900 (15分 = 900秒)と指定していたのと同じようなキャッシュ設計となったといえます。
このように、Next.js 16のCache Componentsにより、「Cache ComponentsでないコンポーネントはSuspenseでラップしてPPR」すればよく、見通しがよくなった印象です。
閑話休題: Client Components
ここまでは、RSC登場からNext.jsにおけるデフォルトのレンダリングモデルとなったServer Componentsのお話でした。
では、 Posts コンポーネントがClient Componentだったらどうなるのか?というのも見てみます。
話をCache Componentsを有効にする前に戻します。
posts.tsx を以下のように変更し、Client Componentとします。
前提として、 getPosts はClient Componentから呼び出せるように改変済みとします。
"use client";
import { getPosts } from "../_lib/getPosts";
import { useState, useEffect } from "react";
type Post = {
id: string;
title: string;
};
const Posts = () => {
console.log("Posts Component is rendered");
const [contents, setContents] = useState<Post[]>([]);
const fetchPosts = async () => {
const { contents } = await getPosts();
setContents(contents);
}
useEffect(() => {
fetchPosts();
}, []);
return ( "use client";
import { getPosts } from "../_lib/getPosts";
import { useState, useEffect } from "react";
type Post = {
id: string;
title: string;
};
const Posts = () => {
console.log("Posts Component is rendered");
const [contents, setContents] = useState<Post[]>([]);
const fetchPosts = async () => {
const { contents } = await getPosts();
setContents(contents);
}
useEffect(() => {
fetchPosts();
}, []);
return ( ビルドすると、以下のようにルートページはStatic Renderingされています。
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content ただし、データ取得はクライアント側で行われるため、事前生成されるHTMLにはmicroCMSの情報は含まれません。
<body class="geist_a71539c9-module__T19VSG__variable geist_mono_8d43a2aa-module__8Li5zG__variable antialiased">
<div hidden=""><!--$--><!--/$--></div>
<main>
<h1>Posts</h1>
<ul></ul>
</main>
いろんなscriptタグたち
</body> <body class="geist_a71539c9-module__T19VSG__variable geist_mono_8d43a2aa-module__8Li5zG__variable antialiased">
<div hidden=""><!--$--><!--/$--></div>
<main>
<h1>Posts</h1>
<ul></ul>
</main>
いろんなscriptタグたち
</body> このように、PPR登場以前も、Client Componentsを利用することで、動的な部分を除いて事前ビルドすること自体は可能でした。
PPRの登場により、Server Componentsにおいても動的な部分を除いて事前ビルドすることが可能になった、という整理もできそうです。
おわりに
開発者目線では、キャッシュまわりのAPIが簡潔になったり、PPRによって動的な領域を含むページでもパフォーマンスを高められたりと、いいことづくめなアップデートという印象です。
SaaS提供事業者として障害対応などをする立場では、コンポーネント単位でのレンダリング設計は、調査の初動における「あたり」をつけづらくなるような気もします。
なんにせよ、キャッシュまわりの複雑さから忌避されがちだったApp Routerが普及する土台が整ったのではないでしょうか。
おわり。