🤠

Next.js 14 の Partial Prerender とVercelの事情

2023/12/06に公開

Qiita Next.js Advent Calendar 2023 の6日目の記事です。

Vercel Next.js 14の新機能、Partial Prerender(PPR)の解説記事です。

PPR とは?

Partial Prerenderとは、その名の通り、一つのページの部分的な静的HTML生成になります。

例えば、Zennのこのページをレンダリングするとしましょう。ブログの本文部分はあらかじめ静的なHTMLに事前レンダリングし、CDNエッジ・キャッシュに配置しておきます。ログインのアバターやページ下部の、次に読む記事(レコメンド記事)、はユーザのログイン・クッキーに基づいて動的にサーバ側でレンダリングします。念の為繰り返します、サーバー側でレンダリングします。

爆速表示で画面が真っ白になる時間がなくなり、かつ、コンテンツを動的に生成する良いとこどりになります。

PPRを使用したページをこちらのデモサイトで見ることができます。ページ全体は静的レンダリングしたものですが、赤色のドットで示した画面領域(おすすめ、配送日)は意図的に遅延が入れてあり、動的なストリーミング配信になります。

それって、昔からありましたよね?

はい、動的表示部分をSuspenseとストリーミングで遅延配信する仕組みはNext.js 13からありましたし、PPRでも同じ仕組みです。詳細は私のこちらの記事をご覧ください。

https://zenn.dev/tfutada/articles/36ad71ab598019

今回の変更点は、Suspenseを包む親ページの部分が動的ではなく、事前レンダリングされCDNエッジにキャッシュされることになります。13の場合は動的レンダリングでした。
この包んでいる殻のことを英語ではshell(シェル)と言います。ダイナミックな部分をhole(ホール)と言います。

Vercelの事情

Vercelは営利企業であり、CloudflareとAWSのインフラを使用してサービスを提供し、Vercelの顧客からクラウドの使用料を頂戴するPaaS的なビジネスモデルです。
さて、Vercelの顧客はクラウドのコストを上回る収益を出さないと赤字になります。収益を上げるには、SEO対策ページのレスポンスがとても重要になります。つまり、Vercelは顧客の課題を解決し、Win-Winの関係にする必要があります。
この問題の手っ取り早い解決策は、静的なHTMLページをCDNエッジから配信することです。Vercelでも一時期SSG(プリレンダリング)を押していたこともあります。

しかしながら、クッキーを利用してユーザによってコンテンツをダイナミックに変えた方が儲かるよね、と時代は移り変わります。欧米では、パーソナライズAIレコメンド機能を導入していないサービスはないと言って良いでしょう。また今年は、OpenAI APIのようなLLMとの連携などもみられるようになりました。VercelもSSRやサーバー・コンポーネントApp Routerへと移行します。

あちらを立てるとこちらが立たなくなります。リクエスト時に動的にレンダリングする場合、どうしてもリクエスト一発目のコールド・スタート問題が発生します。特にVercelはAWS Lambdaを使用しているため、インスタンスのスピンアップに時間がかかります。そこで、Cloudflare Workerのエッジ・ランタイム(V8 Isolates)を導入し、コールド・スタートをゼロにしたのですが、V8のサブセットであるが故に既存ライブラリが動かなかったり、設定が複雑なこともあり、開発者ウケがあまり良くありませんでした。特に、ローカルで動くけど、Vercelにデプロイしたら動かないというハマりポイントがあり、やっぱりコンテナが安心だよねとビジネスではなるでしょう。

そこで、固定のコンテンツはプリレンダリング、動的に変えたい部分はサーバー・コンポーネント + ストリーミング(Suspense)という、ハイブリッド方式のPPRの爆誕となりました。

We've heard your feedback. There's currently too many runtimes, configuration options, and rendering methods to have to consider. You want the speed and reliability of static, while also supporting fully dynamic, personalized responses.
Having great performance globally and personalization shouldn't come at the cost of complexity.
公式より。PPR誕生の動機。

Jamスタックで良くないですか?

いわゆる、Ajax(TanStack)でクライアント側からサーバ側のAPIを叩くアーキテクチャです。Javascript + API + Marckup(HTML)の頭文字でJamスタックと称します。Next.jsでも結局、クライアント・コンポーネントだらけになり、TanStackでAPIルータ、パススルー構成にしている方も多いかと思います。

このSPA的なJSヘビーなアーキテクチャーの問題点は、クライアント側へダウンロードするJSのサイズやJSコードの実装が見えてしまうこと、クラ・サバ間のHTTPプロトコルのオーバーヘッド、APIエンドポイントやJSONレスポンスでのセキュリティホール、情報漏洩、クラサバで同じようなコードを重複実装してしまうなどの問題があります。またこの場合、Next.js使うメリットよりデメリットが多くなります。

この辺りの話は、先日のNext.js Conf 2023での説明がわかりやすいです。
How Next.js is delivering React’s vision for the future

インストール方法

前置きが長くなりましたが、実際にPPRを使用したページを作ってみたいと思います。と言っても特にやることはなくて、遅延させたいコンポーネントをSuspenseで囲むだけです。

ソースコードをこちらにおきます。

Next.js 14のプロジェクトを新規に作成します。その後、canary版をインストールします。

インストール
npx create-next-app@latest
pnpm install next@canary

next.config.jsを修正します。PPRはプレビューなので有効化する必要があります。

next.config.js
experimental: {
  ppr: true,
},

コードを書きます。

シェルになるページをApp Routerで作成します。Cartコンポーネントが非同期に遅延レンダリング(ストリーミング送信)される箇所になります。このページ自体はプレレンダリング(静的HTML)になることに注意してください。

page.tsx
import {Suspense} from "react";
import Cart from "@/app/Cart";

export default function Page() {
    return (
        <main>
            <h1>Products</h1>
            <Suspense fallback={<div>loading...</div>}>
                <Cart/>
            </Suspense>
        </main>
    );
}

次に、Cartコンポーネントを実行します。このサンプルではsetTimeout()で擬似的な非同期処理を入れています。実際にはデータベース呼び出しや外部APIコールなどのI/Oを実装することになります。逆にI/Oの呼び出しがなく、インメモリ+CPUのみのロジックであればSuspenseする必要はありません。

Cart.tsx
export default async function Cart() {
    // Let's create an artificial delay
    await new Promise(resolve => setTimeout(resolve, 3_000));

    return <div>AAAAAAAAAAAAAAAA</div>
}

実行します

pnpm build
pnpm start

出力です。ルートが半月のアイコンでPartial Prerenderingになっているのが分かります。

ブラウザからページを開きます。爆速でページが開き、3秒後にAAAAAAAAAAAAAAAAと表示されると思います。

telentを使用して、TCPソケット直で接続して試してみました。

ローカルでサーバを起動しているので、HTTP/1.1chunkedでストリーミング送信されます。template id=B:0のところがプレース・フォルダーになっています。3秒後に次のチャンクを受信します。コンテンツのAAAAAAAAAAAAAAAAを受け取ります。JSでプレース・ホルダーに差し込みます。

Discussion