🤠

Next.js の Server Actions と Vercel KV の話

2023/05/29に公開

本ブログは、Vercel(Next.js)の新機能のServer ActionsVercel KVの解説になります。

Vercel KVとはインメモリDBのRedisを使用したものです。ユースケースとしてセッション情報の保持があります。

Server ActionsとはtRPCやgRPCのようなリモートプロシージャコールのようなものです。サーバー側の関数をあたかもローカル関数を実行するかのように記述できます。fetchaxiosを使わずにサーバーとHTTP通信ができます。

このブログは、簡単なショッピングカートを実装しながらこの新機能の理解を深めていくチュートリアルになります。ソースコードはこちらにあります。

また、Next.js 13.4でステーブルとなったApp RouterReact Server Componentsの利用が前提となります。ご存知ない方はまずはこちらの記事を覧ください。

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

プロジェクトの作成

新規プロジェクトを作成します。すべてデフォルト値を選択します。App Routerを選択。

next.jsプロジェクトの作成
npx create-next-app@latest

作成後、Vercelにデプロイしてください。(説明は省略)

Vercel KVの設定

Vercelのダッシュボードから作業します。先ほど作成したプロジェクトからVercel KVを利用できるようにする必要があります。ポチポチすれば簡単にできますので説明はしません。やり方はこちらのビデオをご覧ください。

https://vercel.com/docs/storage/vercel-kv/quickstart

KV SDKの設定

TypescriptのSDKライブラリをインストールします。Vercel謹製のもので、シリアライズ・デシリアライズをよしなにやってくれるのでRedisを知らなくても使えます。Typespriptにも対応しています。

SDK
yarn add @vercel/kv

Vercel CLIも最新版にアプデしておきます。

CLI
yarn global add vercel@latest

ローカル開発環境からKVにアクセスできるように環境変数を取得します。

環境変数
vercel env pull .env.development.local

KVのコード

Hello World的な簡単なコードを書いてみましょう。KVデータベースに値を出し入れしてみましょう。APIとして実装します。route.tsに記述します。

app/myapi/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);
}

実行します。

Next.js起動
yarn dev
cURL
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を下記のように修正します。

next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
    experimental: {
        serverActions: true,
    },
}

module.exports = nextConfig

まず手始めにHello World的なコードを書いて動作確認をしましょう。App Routerを使用します。page.tsxに実装します。

FormのSubmitボタンを押すと、サーバ側でハンドラーaddItemを実行します。

app/myaction1/page.tsx
// デフォでサーバーコンポーネントになります。
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に実装します。

  1. サーバー・コンポーネントの先頭でKVの取得処理kv.get()を追加
  2. Server ActionsのaddItem()にKVの更新処理kv.set()を追加

Cart型のリストをKVに格納します。[{id, quantity: 1}, ...]

app/myaction2/page.tsx
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に切り出します。

app/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
}

クライアント側のイベントハンドラーからサーバアクションを叩きます。

app/myaction3/page.ts
'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()を使用して、複数のサーバーアクションをコンカレントに叩きます。

app/myaction3/page.ts
'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>
        </>
    )
}
app/myaction3/page.ts
'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.

https://nextjs.org/docs/getting-started/react-essentials#moving-client-components-to-the-leaves

参考資料

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