Next.js の Server Actions と Vercel KV の話
本ブログは、Vercel(Next.js)の新機能のServer ActionsとVercel KVの解説になります。
Vercel KVとはインメモリDBのRedisを使用したものです。ユースケースとしてセッション情報の保持があります。
Server ActionsとはtRPCやgRPCのようなリモートプロシージャコールのようなものです。サーバー側の関数をあたかもローカル関数を実行するかのように記述できます。fetch
やaxios
を使わずにサーバーとHTTP通信ができます。
このブログは、簡単なショッピングカートを実装しながらこの新機能の理解を深めていくチュートリアルになります。ソースコードはこちらにあります。
また、Next.js 13.4でステーブルとなったApp RouterのReact Server Componentsの利用が前提となります。ご存知ない方はまずはこちらの記事を覧ください。
プロジェクトの作成
新規プロジェクトを作成します。すべてデフォルト値を選択します。App Router
を選択。
npx create-next-app@latest
作成後、Vercelにデプロイしてください。(説明は省略)
Vercel KVの設定
Vercelのダッシュボードから作業します。先ほど作成したプロジェクトからVercel KVを利用できるようにする必要があります。ポチポチすれば簡単にできますので説明はしません。やり方はこちらのビデオをご覧ください。
KV SDKの設定
TypescriptのSDKライブラリをインストールします。Vercel謹製のもので、シリアライズ・デシリアライズをよしなにやってくれるのでRedisを知らなくても使えます。Typespriptにも対応しています。
yarn add @vercel/kv
Vercel CLIも最新版にアプデしておきます。
yarn global add vercel@latest
ローカル開発環境からKVにアクセスできるように環境変数を取得します。
vercel env pull .env.development.local
KVのコード
Hello World
的な簡単なコードを書いてみましょう。KVデータベースに値を出し入れしてみましょう。APIとして実装します。route.ts
に記述します。
import {kv} from '@vercel/kv'; // SDKをインポート
import {NextResponse} from 'next/server';
export async function GET() {
// KVにデータをインサート。キー cart:123, 値 abc
await kv.set(`cart:123`, "abc");
// KVからデータを取得
const cart = await kv.get('cart:123');
return NextResponse.json(cart);
}
実行します。
yarn dev
curl http://localhost:3000/myapi
"abc"
kv.set(キー,値)
とkv.get(キー)
で値を出し入れします。非同期なのでawaitを忘れないようにしてください。値には文字列、配列などのオブジェクトをそのまま突っ込めます。
yarn dev
の出力です。https://guiding-cheetah-
というのはKVサーバのエンドポイントになります。.env.development.local
を見ると、ご自身のエンドポイントとトークンが分かります。
yarn devコンソールログ
Server Actions
次はServer Actionsの説明です。サーバーアクションはまだアルファ版です。有効にするためにはnext.config.js
を下記のように修正します。
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: true,
},
}
module.exports = nextConfig
まず手始めにHello World
的なコードを書いて動作確認をしましょう。App Router
を使用します。page.tsx
に実装します。
FormのSubmitボタンを押すと、サーバ側でハンドラーaddItem
を実行します。
// デフォでサーバーコンポーネントになります。
export default function ActionForm() {
// サーバーアクション。サーバー側(Node.js)で実行される関数
async function addItem(formData: FormData) {
'use server'; // これが必要
console.log('サーバあくしょん')
console.log(formData.get('id'));
}
return (
<div>
<form action={addItem}> // サーバーアクションに紐づける
<input type="text" name="id" defaultValue="A001" className="bg-gray-500"/>
<button type="submit">カートに追加</button>
</form>
</div>
)
}
Server Actionsの基本形です。
async function addItem(formData: FormData) {
'use server'; // This is required for server actions.
(ここにサーバ側の処理を実装。DB,APIなどを叩く。ex. process.env.API_KEY)
asyncをつけて非同期関数にします。
1行目に'use server'
とおまじないを書きます。
これはサーバー側で動くJSだよ、と宣言するわけです。その後に続けてロジックを実装します。外部のDBやAPIに非同期にアクセスすることができます。ここで実装したコードはブラウザ側にダウンロードされません。そのため、APIキーやトークンが必要なシステムへアクセスするコードを書いても安心です。
ブラウザからhttp://localhost:3000/myaction1
を開きます。
Chromeブラウザ
カートに追加
ボタンを押すと、yarn dev
のコンソールにconsole.log
が出力されます。
yarn devコンソールログ
Content-Type: multipart/form-data
で送信されます。
Chrome dev tool ペイロード
Vercelダッシュボードからサーバアクションのログも確認できます。
KVと合体させる
次は、KVのコードを追加します。以下の2つの機能をapp/myaction2/page.tsx
に実装します。
- サーバー・コンポーネントの先頭でKVの取得処理
kv.get()
を追加 - Server ActionsのaddItem()にKVの更新処理
kv.set()
を追加
Cart型のリストをKVに格納します。[{id, quantity: 1}, ...]
import {kv} from "@vercel/kv"; // グローバル・シングルトン。SC間でシェアする。
import {revalidatePath} from "next/cache";
interface Cart { // KVのデータ型
id: string; // 商品ID 例) A001
quantity: number; // 数量 ハードコード
}
const KEY = 'cart:456' // セッションID。実際にはクッキーから取得
export default async function ActionForm() { // 非同期関数です。サーバ側で動きます。
// KVからCart一覧を取得
let cart = await kv.get<Cart[]>(KEY); // データフェッチはサーバ側でやる。
// サーバーアクション
async function addItem(formData: FormData): Promise<void> {
'use server'; // 忘れずに
const id = formData.get('id') as string;
const vals: Cart[] = [...(cart ?? []), {id, quantity: 1}]
await kv.set(KEY, vals); // KVにCartアイテムを保存。JSON.stringfyは不要
revalidatePath('/myaction2') // Server Mutation ページを再ロード
}
return (
<>
// カートの中身を一覧表示
<div> // カートの中身をループで表示
{cart?.map((item: Cart) => (
<div key={item.id}>
{item.id} : {item.quantity}
</div>
))}
</div>
// 入力フォーム
<form action={addItem}>
<input type="text" name="id" defaultValue="A001" className="bg-gray-500"/>
<button type="submit">カートに追加2</button>
</form>
</>
)
}
VercelのKVのSDKがよしなにシリアライズをしてくれるため、JSON.stringfy()
等をする必要はありません。また、インターフェースCart
でレスポンスの型情報を与えることができます。あまり本質ではないためKEY='cart:456'
をハードコードしちゃいましたが、実際にはクッキーからセッションIDを取得することになるかと思います。import { cookies } from 'next/headers';
実行しましょう。
/mypage2
を開きます。カートに追加2
ボタンを押すとアイテムが一覧に追加されていきます。
Chromeブラウザ
Next.jsサーバ(yarn.dev)のコンソールに下記のようなログが出力されます。
https://guiding-cheetah-
というのはKVのエンドポイントになります。サーバ側(Node.js)からKVへリクエストが飛んでいることが分かります。
yarn devコンソールログ
クライアント・コンポーネント
さて、別の話になります。
実は、クライアントコンポーネントの中にサーバーアクションを定義できない制約があります。
useState
やカスタムフックなどを使用したい場合は、use client
を指定してクライアントコンポーネントにする必要があるわけですが、そうした場合にサーバーアクションを同居できないわけです。
回避策として、別ファイルにサーバアクション定義して、クライアントコンポーネントからimportします。
サーバーアクションを/myaction3/_myactions.ts
に切り出します。
'use server'; // このファイル全体がサーバー側のJSとみなされる。
import {revalidatePath} from "next/cache";
export async function addItem(value: string) {
// ここで 'use server' は不要
console.log('addItem', value);
revalidatePath(`/myaction3`); // server mutation
}
クライアント側のイベントハンドラーからサーバアクションを叩きます。
'use client'; // クライアントコンポーネント
import {addItem} from "@/app/myaction3/_myactions"; // サーバーアクション
import {useTransition} from "react";
export default function ActionForm() {
let [isPending, startTransition] = useTransition();
const param1 = '123'
// これはクライアント側で動くイベントハンドラー
const handler = async (p: string) => {
await addItem(p) // サーバーアクションを叩く
}
// 🙅 サーバーアクションをクライアント・コンポーネント内に記述できません。
//async function addItem(formData: FormData): Promise<void> {
// 'use server'; // ダメよ!
//}
return (
<>
<button onClick={() => startTransition(() => handler(param1))}>
Add To Cart
</button>
</>
)
}
promise.all
次は正しいやり方なのか定かではありませんが、興味深いので参考までにご紹介します。
Promise.all()
を使用して、複数のサーバーアクションをコンカレントに叩きます。
'use client';
import {addItem1, addItem2} from "@/app/myaction4/_myactions";
export default function ActionForm() {
const handler = async () => {
const [ret1, ret2] = await Promise.all([
addItem1("abc"),
addItem2("123")
]);
console.log(ret1)
console.log(ret2)
}
return (
<>
<button onClick={handler}>
Add To Cart
</button>
</>
)
}
'use server';
export async function addItem1(value: string) {
console.log('addItem1', value);
return value
}
export async function addItem2(value: string) {
console.log('addItem2', value);
return value
}
一応動くことは動きます。ただし逐次的にリクエストが発火しています。
Chrome Dev Tool ネットワーク
アクションのエンドポイントはどこ?
開いているページhttp://localhost:3000/myaction4
になります。
それぞれのサーバーアクション関数にはユニークなIDが振られています。HTTPヘッダーでNext-Action
にIDを渡して呼び出しているのが分かります。
Chrome Dev Tool HTTPヘッダー
POSTのペイロード text/plain
デバッガーでサーバー側をブレークしてみると、サーバーアクションの関数にそれぞれIDが振られているのが分かります。
WebStorm サーバー側のデバッグ
同期関数と非同期関数の話
サーバーコンポーネントとクライアントコンポーネントがごっちゃになる、見分けがつかないという人がいるかと思います。コツとして、サーバーは非同期関数、クライアントは同期関数と覚えると良いです。
同期関数の中で非同期関数を待つことはできません。次のようなコードは書けません。
function client() {
const result = await server() // 中断することはできません
return result
}
クライアントコンポーネントの中でサーバーコンポーネントをレンダリングするには、サーバーコンポーネントのレンダリング結果をawaitする必要があり、それはできないということです。
そもそもクライアント側のReactコンポーネントは数秒の間に何度も再レンダリングします。その度にサーバーに膨大なトラフィックが飛んでしまいます。
その逆のひっくり返したパターンは可能です。注意点としてクライアントコンポーネントもサーバ側でレンダリング処理されます。next/dynamicを使用すると回避できるそうです。
async function server(id) {
const post = await getPost(id) // サーバー側でまち
return <Client post={post}/>
}
// <Client serverAction={serverAction} // サーバーアクション関数の参照も渡せます。
もしくはコンポジットにします。
<Client>
<Server> // レンダリング結果をClientのpropsに渡す。
</Client>
function Client(props) {
return <>
{props.children} // スロットにSCをあとから差し込むイメージ
<>
}
Suspenseと組み合わせることでサーバーコンポーネントは威力を発揮します。Server1は記事詳細、Server2はコメントにすることで、記事詳細がコメントに足を引っ張られずに爆速で表示することが可能になります。
SPAでは当たり前にやっていたことを、サーバー側でもやってやろうという話です。
<Client>
<Suspense>
<Server1>
</Suspense>
<Suspense>
<Server2>
</Suspense>
</Client>
公式でもクライアントコンポーネントをなるべく葉っぱの部分に移動せよとの記述があります。
To improve the performance of your application, we recommend moving Client Components to the leaves of your component tree where possible.
参考資料
Next.js公式 Server Actions
Vercel公式 KV
Next.js App Router: Routing, Data Fetching, Caching
Next.js Server Actions... 5 awesome things you can do
分からないことはDiscordでどんどん質問しましょう。初歩的な質問や、はちゃめちゃな英語の人もたくさんいますので大丈夫です。
Next.js Discord
Discussion