🥹

【PHPと一緒だなんて言わないで】Next.js@13.4によるZero API Web Application

2023/05/25に公開3

はじめに

タイトルで大袈裟にZero API Web Applicationという名前をつけてしまいましたが、要するにNext.jsのversion13.4のServer ActionとReact Server Componentを活用してAPIを作らない・叩かないWeb Applicationを開発するというものです。

今回は趣味で作った麻雀のゲームを例に、Next.js@13.4でAPI要らずなアプリケーション開発及び、開発で得た知見を紹介できたらと思います。

補足ですが、今回開発したアプリケーションの概要としては、麻雀のさまざまな局面に応じて「あなたならどの牌を切る?」というアンケート型のゲームです。

話すこと

今回以下の二点に絞って知見を共有できたらと思います。

  1. RSCとserver actionsでZero APIの実現
  2. ファイルベースのOGP・favicon

成果物とその構成

今回開発した成果物は、以下のような構成で開発させていただきました。

  • フロントエンド・バックエンド
    • Next.js@13.4
  • データベース
    • Supabase
  • ホスティング
    • Vercel

※ データベース周りに関して、Vercel Postgres + なんらかのORMという構成も考えてみましたが、Vercel PostgresだとHobbyプランユーザーの僕には物足りなかった + Supabaseのjsのクライアントライブラリを使用すればORMライクにクエリを記述できるのでSupabaseを採用しました。

また、局面・手配の問題の生成にopenAIのAPIを利用しました。
openAIのAPIのレスポンスをいい感じにJSONにしてくれるライブラリを社内の同僚が開発しており、今回はそちらを利用させていただきました。
https://github.com/2ndPINEW/simple-prompt-executer/


実際のゲームはこちらです
https://chot-nanikiru.vercel.app/

ソースコードはこちらになります
https://github.com/msy7822-ux/chot-nanikiru

RSCとserver actionsでZero APIの実現

今回の開発を通じて、個人的に得た知見で結構大きいなと思った部分は、やはり「Zero APIなCRUD処理」の実装です。今回のアプリケーションでいうところのfetch部分とレコードのcreate部分の実装に際してAPI(API Route)を一切作ることなくCRUD処理及びUIの実現を行いました。

RSCでのfetch処理

まずはDBからデータをfetchする部分についてです。
こちらではReact Server Compoentの関数内でSupabaseに直接アクセスしてデータを取得します。

起点となるサーバコンポーネントから子孫のサーバコンポーネントないしはクライアントコンポーネントにバケツリレーでデータを渡していきます。

実際のコードはこちらです。

sample.tsx
export const Nanikiru = async ({ situationId }: { situationId: string }) => {
  const { data: majanSituation } = await supabase
    .from("situations")
    .select("*")
    .eq("id", situationId)
    .single();

  const { data: votes } = await supabase
    .from("votes")
    .select("*")
    .eq("situation_id", `${situationId}`);

  return (
    <div className="flex flex-col gap-5">
      <Situation record={majanSituation!}></Situation>
      <AnswerOptions
        situationId={situationId}
        tehai={majanSituation?.tehai as PaiType[]}
        tsumo={majanSituation?.tsumo as PaiType}
        isDisplay
      ></AnswerOptions>

      <Results
        votes={votes}
        tehai={majanSituation?.tehai as PaiType[]}
        tsumo={majanSituation?.tsumo as PaiType}
      ></Results>
    </div>
  );
};

server actionsによるcreate処理

次にレコードのcreate処理を先日発表されたserver actionsを使ってcreate処理を書いていきます。

server actionsを使用するためにnext.config.jsに設定を追加します。

next.config.js
module.exports = {
  experimental: {
    serverActions: true,
  },
};

server actionのcreate部分はこちらです。
今回はクライアントコンポーネントからserver actionsを呼び出したいので、ファイルのトップレベルで"use server"ディレクティブを宣言し、server actionsを定義します。

app/actions/majan.ts
"use server";

import { supabase } from "@/libs/supabase";
import { PaiType } from "@/types/paiType";
import { v4 as uuidv4 } from "uuid";

export const createVote = async (situationId: string, answer: PaiType) => {
  const uuid = uuidv4();

  await supabase.from("votes").insert({
    id: uuid,
    situation_id: situationId,
    answer: answer,
  });
};

以下はserver actionsの実際の呼び出し部分です。

submit.tsx
"use client";

type Props = {
  selectPai: PaiType | null;
  situationId: string;
  close: () => void;
  clear: () => void;
};

export const SubmitButton = ({
  selectPai,
  close,
  clear,
  situationId,
}: Props) => {
  const notify = (text: string) => toast(text ?? "");
  const hamdleOnClick = async () => {
    if (!selectPai) return;

    try {
      // client componentからserver actionsを呼び出す
      await createVote(situationId, selectPai);

      notify("回答を送信しました");
      clear();
      close();
    } catch (error) {
      console.log(error);
    }
  };

  return (
      <button
        type="button"
        onClick={() => hamdleOnClick()}
        disabled={!selectPai}
        className="py-2 px-6 border w-full border-gray-300 bg-white rounded-[6px] font-bold disabled:text-gray-400"
      >
        回答する
      </button>
  );
};

router.refreshでサーバーコンポーネントを再レンダリング

実はこのままだと、server actionsでレコードのcreateを実行できるのですが、実行後にレコードを再取得(サーバコンポーネントの再レンダリング)が必要になります。

ここでユーザーにリロードさせるのはなんかしょっぱいですよね😅
refreshボタンみたいな強制的にリロードするボタンを設置することも考えましたが、今回はもっとスマートにレコードの更新をUIに反映させて行こうと思います。

そこで今回使用するのがnext/navigationのuseRouterを利用したrouter.refresh()という関数です。

router.refresh()とはなに奴???

Next.jsの公式ドキュメントによると、

サーバーに新しいリクエストを行い、データリクエストを再取得し、Server Componentを再レンダリングする。

クライアントは、影響を受けていないクライアント側のReact(useStateなど)やブラウザの状態(スクロール位置など)を失うことなく、更新されたReact Server Componentのペイロードをマージすることができます。

クライアントコンポーネントに影響することなくサーバコンポーネントを再レンダリングしてデータを更新してくれるいわゆるいい感じにしてくれる奴です。

実際にrouter.refresh()の処理を挟んでみます。

submit.tsx
...

}: Props) => {
  const notify = (text: string) => toast(text ?? "");
+ const router = useRouter();
  const hamdleOnClick = async () => {
    if (!selectPai) return;

    try {
      await createVote(situationId, selectPai);

      notify("回答を送信しました");
      clear();
      close();

+     router.refresh();
    } catch (error) {
      console.log(error);
    }
  };

...

これにより、server actionsでcreate処理を実行した後にいい感じにデータの更新・表示の更新が行われます。

ファイルベースのOGP・favicon

次にNext.js@13.4のOGP画像及びfaviconについてです。
Next.js@13.4ではOGPもfaviconもmetaタグによる設定ではなく、基本的にファイルベースでの設定・管理にすることで、pathnameやparamsなどをもとに動的な情報OGP画像やfaviconを生成することができます。

OGP画像

ファイルパス: app/[id]/opengraph-image.tsx
opengraph-image.tsxで動的な[id]の値を使用できます。

以下のサンプルは今回のアプリケーションの一部のOGP画像の定義です。
paramsのid情報を元に毎度、動的な画像を生成していきます。

app/[id]/opengraph-image.tsx
import { supabase } from "@/libs/supabase";
import { ImageResponse } from "next/server";

export const runtime = "edge";

export const alt = "altテキスト";
export const size = {
  width: 1200,
  height: 630,
};

export const contentType = "image/png";

export default async function Image({ params }: { params: { id: string } }) {
  const { id } = params;

  const { data: situation } = await supabase
    .from("situations")
    .select("*")
    .eq("id", id)
    .single();

  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 128,
          background: "#29711a",
          width: "100%",
          height: "100%",
          display: "flex",
          textAlign: "center",
          alignItems: "center",
          justifyContent: "center",
        }}
      >
        <div style={{ display: "flex", gap: "10px" }}>
          {situation &&
            situation.tehai.map((pai) => (
              <>
                <div
                  style={{
                    background: "white",
                    display: "flex",
                    borderRadius: "6px",
                    height: "100px",
                    width: "75px",
                    justifyContent: "center",
                  }}
                >
                  {/* eslint-disable-next-line @next/next/no-img-element */}
                  <img
                    src={`https://chot-nanikiru.vercel.app/pais/${pai}.png`}
                    alt=""
                    width={100}
                    height={100}
                  />
                </div>
              </>
            ))}
        </div>
      </div>
    ),
    {
      width: 1200,
      height: 600,
    }
  );
}

favicon

faviconもOGP画像と同様にファイルベースで設定・管理可能です。
画像ファイルとして管理する場合もカスタムのコンポーネントとして管理する場合でもappディレクトリ配下置けばいい感じにNext.jsがfaviconの設定をやってくれます。
※カスタムコンポーネントの場合は、app/icon.tsxのようにして命名すればOKです

以下のサンプルがカスタムのfaviconコンポーネントです。
OGP画像と非常に似ています。

app/icon.tsx
import { ImageResponse } from "next/server";

export const runtime = "edge";

export const size = {
  width: 32,
  height: 32,
};
export const contentType = "image/png";

export default function Icon() {
  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 24,
          background: "white",
          width: "100%",
          height: "100%",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          color: "white",
        }}
      >
        <div style={{ display: "flex", gap: "10px" }}>
          <>
            <div
              style={{
                background: "white",
                display: "flex",
                borderRadius: "6px",
                height: "100px",
                width: "75px",
                justifyContent: "center",
              }}
            >
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img
                src={`https://chot-nanikiru.vercel.app/pais/j7.png`}
                alt=""
                width={55}
                height={100}
              />
            </div>
          </>
        </div>
      </div>
    ),
  );
}

まとめ

Next.js@13.4以降でかなり可能性が広がったように感じています。
はやくReact Server Component及びserver actionsを業務でも使いたいですね🥹

とはいえ、開発の規模によってはどうしてもAPIを実装する必要は大いにあると思います。
(こんかいのような小規模開発では非常にマッチしてる気がする)

それでは、良いNext.jsライフを🥳!!!

参考情報

https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions
https://nextjs.org/docs/app/api-reference/functions/use-router
https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation/og-image-examples
https://nextjs.org/docs/app/api-reference/file-conventions/metadata/app-icons


Discussion

satosato

そこで今回使用するのがnext/navigationのuseRouterを利用したrouter.refresh()という関数です。

revalidatePathとの使い分けが何かありますか?

msy.msy.

個人的な認識としては両者に以下のような違いがあると認知しております!

  • router.refresh(): サーバーコンポーネントを再レンダリングし、その際にデータの再取得も行われ、画面表示上のデータも更新される
  • revalidatePath: 特定のパスのキャッシュの再検証を行い、キャッシュされたデータを更新することで画面表示上のデータも更新される(on demand ISRぽい印象)

といったような感じです!

今回のアプリケーションでrouter.refresh()の方を採用した理由としては、まだ完全に理解できてないので確実なことは言えませんが、revalidatePathの挙動がかなり不安定な印象を受けたためです.(実装が誤っていたらごめんなさい)

具体的には「何度か麻雀牌の投票を行うとキャッシュの再検証のところで、バックエンド側でデータの更新があるにも関わらず、キャッシュされたデータが優先して表示される」という現象です!

一方でrouter.refresh()はきちんと毎回最新のバックエンドのデータに合わせて更新してくれたので今回はこちらを採用しました!