【PHPと一緒だなんて言わないで】Next.js@13.4によるZero API Web Application
はじめに
タイトルで大袈裟にZero API Web Applicationという名前をつけてしまいましたが、要するにNext.jsのversion13.4のServer ActionとReact Server Componentを活用してAPIを作らない・叩かないWeb Applicationを開発するというものです。
今回は趣味で作った麻雀のゲームを例に、Next.js@13.4でAPI要らずなアプリケーション開発及び、開発で得た知見を紹介できたらと思います。
補足ですが、今回開発したアプリケーションの概要としては、麻雀のさまざまな局面に応じて「あなたならどの牌を切る?」というアンケート型のゲームです。
話すこと
今回以下の二点に絞って知見を共有できたらと思います。
- RSCとserver actionsでZero APIの実現
- ファイルベースのOGP・favicon
成果物とその構成
今回開発した成果物は、以下のような構成で開発させていただきました。
- フロントエンド・バックエンド
- Next.js@13.4
- データベース
- Supabase
- ホスティング
- Vercel
※ データベース周りに関して、Vercel Postgres + なんらかのORMという構成も考えてみましたが、Vercel PostgresだとHobbyプランユーザーの僕には物足りなかった + Supabaseのjsのクライアントライブラリを使用すればORMライクにクエリを記述できるのでSupabaseを採用しました。
また、局面・手配の問題の生成にopenAIのAPIを利用しました。
openAIのAPIのレスポンスをいい感じにJSONにしてくれるライブラリを社内の同僚が開発しており、今回はそちらを利用させていただきました。
実際のゲームはこちらです
ソースコードはこちらになります
RSCとserver actionsでZero APIの実現
今回の開発を通じて、個人的に得た知見で結構大きいなと思った部分は、やはり「Zero APIなCRUD処理」の実装です。今回のアプリケーションでいうところのfetch部分とレコードのcreate部分の実装に際してAPI(API Route)を一切作ることなくCRUD処理及びUIの実現を行いました。
RSCでのfetch処理
まずはDBからデータをfetchする部分についてです。
こちらではReact Server Compoentの関数内でSupabaseに直接アクセスしてデータを取得します。
起点となるサーバコンポーネントから子孫のサーバコンポーネントないしはクライアントコンポーネントにバケツリレーでデータを渡していきます。
実際のコードはこちらです。
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に設定を追加します。
module.exports = {
experimental: {
serverActions: true,
},
};
server actionのcreate部分はこちらです。
今回はクライアントコンポーネントからserver actionsを呼び出したいので、ファイルのトップレベルで"use server"ディレクティブを宣言し、server actionsを定義します。
"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の実際の呼び出し部分です。
"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()の処理を挟んでみます。
...
}: 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情報を元に毎度、動的な画像を生成していきます。
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画像と非常に似ています。
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ライフを🥳!!!
参考情報
Discussion
revalidatePathとの使い分けが何かありますか?
個人的な認識としては両者に以下のような違いがあると認知しております!
router.refresh()
: サーバーコンポーネントを再レンダリングし、その際にデータの再取得も行われ、画面表示上のデータも更新されるrevalidatePath
: 特定のパスのキャッシュの再検証を行い、キャッシュされたデータを更新することで画面表示上のデータも更新される(on demand ISRぽい印象)といったような感じです!
今回のアプリケーションで
router.refresh()
の方を採用した理由としては、まだ完全に理解できてないので確実なことは言えませんが、revalidatePathの挙動がかなり不安定な印象を受けたためです.(実装が誤っていたらごめんなさい)具体的には「何度か麻雀牌の投票を行うとキャッシュの再検証のところで、バックエンド側でデータの更新があるにも関わらず、キャッシュされたデータが優先して表示される」という現象です!
一方でrouter.refresh()はきちんと毎回最新のバックエンドのデータに合わせて更新してくれたので今回はこちらを採用しました!
[ 追記 ]
上記の自分が遭遇したrevalidatePathの現象に関するIssueを発見しましたので共有させていただきます。