はじめに
こんにちは! yu-Matsuです。
今回は前回の続編ということで、Next.js初心者がServer Componentsに触れていきたいと思います。
前回のおさらいになりますが、Appディレクトリに慣れるために簡単なWebアプリケーションを構築していきました。仕様は以下の通りでした。
- ログインページでユーザー名とパスワードを入力するとログイン出来る
- ログイン後、TOPページに遷移する。TOPページではChatGPTと会話できるChatページへの遷移ボタンが配置されている
- ChatページではChatGPTとテキストベースで会話が出来る(下画像のようなイメージ)
最終的なディレクトリ構成は以下の通りです。
nextjs-project/
|- api/
| chatgpt.ts
|
|- app/
| |- login/
| | page.tsx
| | login.module.css
| |- chat/
| | page.tsx
| | chat.module.css
| global.css
| layout.tsx
| page.tsx
| page.module.css
|
|- components/
| |- Header/
| | index.tsx
| | header.module.css
| |- TopContents/
| | top.module.css
| | index.tsx
| |- ChatComponent/
| | chat_component.module.css
| | index.tsx
| |- PrivateRoute/
| index.tsx
|- hooks/
| use-auth.tsx
| auth.ts
|
...
Server Componentsとは
色々な記事で詳しく解説されているため詳細は省きますが、簡単に言うと、サーバー側でコンポーネントを生成しクライアント側に返し、クライアント側で返ってきたものをレンダリングする技術になります。その特性上、useStateやuseEffectなどブラウザが持つ機能を利用することは出来ません。
Server Componentsの使い所は公式のドキュメントにも記載されていますが、クライアント側の機能を利用する場合を除いて基本的にはServer Componentsの利用が推奨されていそうです。実際に、appディレクトリ配下のコンポーネントはデフォルトだとすべてServer Componentsです。
引用:https://nextjs.org/docs/getting-started/react-essentials
Server Componentsと対をなすものとしてClient Componentsがありますが、こちらは従来のReactのコンポーネントと同様です。Server ComponentsからClient Componentsをimportすることは可能ですが、Client Componentsから直接Server Componentsをimportすることは出来ず、以下のように親のServer ComponentからClient ComponentsにChildrenとしてServer Componentsを渡す必要があることに注意です。
import ServerComponent from ...;
import ClientComponent from ...;
/** Server Component */
export const Page = () => {
return (
<ClientComponent>
<ServerComponent />
</ClientComponent>
)
}
実際に触ってみる
それでは実際にServer Componentsに触れてみたいと思います。現在のWebアプリケーションに管理者ページを追加し、そこで利用者一覧を確認出来る、という想定で実装していきたいと思います。まずはAdminページを作り、TOPページから遷移出来るようにします。
nextjs-project/
|- app/
| |- admin/
| | page.tsx
| | login.module.css
...
|- components/
| |- Users/
| | index.tsx
| | header.module.css
...
appディレクトリ配下に管理者ページ用のadminディレクトリ、componentsディレクトリ配下に利用ユーザー一覧表示用のUsersコンポーネントを作成していきます。まず、管理者ページは以下のような感じです。
import styles from "./admin.module.scss";
import { Users } from "@/components/Users";
const Admin: React.FC = () => {
return (
<div className="container">
<h1 className="pageTitle">管理者ページ</h1>
<Users />
</div>
);
};
export default Admin;
次にUsersコンポーネントになります。前回のTopページやChatページのコンポーネントはuseRouterやuseStateなどのReactフックを利用していたためClient Componentsでしたが、今回はユーザー一覧をfetchで取得し、かつReactフックも使わない想定なので、Server Componentsで実装したいと思ます。ユーザー取得のAPIは後ほど本実装するとして、今は動作確認のために仮のAPIとして JSONPlaceholder サービスを利用させていただきます。https://jsonplaceholder.typicode.com/users にアクセスすると10名分のユーザ情報が取得できます。
import styles from "./users.module.scss";
type User = {
id: number;
name: string;
email: string;
};
const getUsers = async () => {
const response = await fetch("https://jsonplaceholder.typicode.com/users");
const users: User[] = await response.json();
console.log(users);
return users;
};
export const Users = async () => {
const userData = await getUsers();
return (
<>
<h3>ユーザー一覧</h3>
<ul className="userList">
{userData &&
userData.map((user) => (
<li key={user.id} className="userItem">
{user.name}
</li>
))}
</ul>
</>
);
};
サーバー上での動きを確認したので、getUsersの中でconsole.logで取得したデータをターミナルで表示するようにしています。後は、Topページから管理者ページに遷移出来るようにTopページのコンポーネントを修正しました。
...
export const TopContents: React.FC = () => {
...
return (
...
<div className="box">
<div>
<p className="userName">ユーザー名:{username}</p>
+ <div>
+ <button
+ className="button"
+ onClick={() => {
+ router.push("/admin");
+ }}
+ >
+ 管理者ページへ
+ </button>
+ </div>
<div>
<button
className="button"
onClick={() => {
router.push("/chat");
}}
>
Chatページへ
</button>
</div>
</div>
</div>
...
それでは実際に動作確認していきます。Topページから管理者ページに遷移するボタンを押下すると...
管理者ページに遷移し、取得した情報が表示されていることが分かります。ターミナルを確認すると、console.logの出力結果が表示されています。(下画像は一部)
Client Componentではブラウザのデベロッパーツールのコンソールに表示されていたものがターミナルに表示されており、サーバ側で処理が動いていることが分かります。
また、ブラウザのネットワークタブも見てみましょう。adminページをリロードした際に、HTMLのデータが返って来ていることが分かります。また、scriptタグのにも文字列形式でデータが格納されています。ここからも、サーバー側でレンダリングされていることが分かります。
後はadminページでもログイン状態を判断するラッパーであるPriveteRouteを追加したいと思います。Usersコンポーネントの前段に以下のようなUsersClientコンポーネント(Client Components)を挟みたいところですが、
'use client';
import { Users } from "@/components/Users";
export const UsersClient: React.FC = () => {
const { username, isLoading } = useAuth();
if (isLoading) {
console.log("loading...");
return <h1>Loading...</h1>;
}
return (
<PrivateRoute>
<Users>
</PrivateRoute>
}
前述の通り、Client Componentsから直接Server Comoponentsをimport出来ないため、親のServer Components(admin/page.tsx)からChlidrenでUsersClientコンポーネントにUsersコンポーネントを渡すようにしています。
app/
|- admin/
| userClient.tsx <- 追加
"use client";
import PrivateRoute from "../../components/PrivateRoute";
import React from "react";
import { useAuth } from "@/hooks/auth_hook";
interface Props {
children: React.ReactNode;
}
export const UsersClient: React.FC<Props> = (props) => {
const { children } = props;
const { isLoading } = useAuth();
if (isLoading) {
return <h1>Loading...</h1>;
}
return <PrivateRoute>{children}</PrivateRoute>;
};
+ import { UsersClient } from "./UsersClient";
const Admin: React.FC = () => {
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>管理者ページ</h1>
+ <UsersClient>
<Users />
+ </UsersClient>
</div>
);
};
現状はユーザーの取得に仮のAPIとしてJSONPlaceholderを利用していますが、もちろんこれで良いわけではなく、実データを取ってきたいところ。前回ChatGPTとの会話履歴をAWS DynamoDBに保存しているとご説明しましたが、ユーザーの情報も同じくDynamoDBの別テーブルに保存してますので、そこからデータを取得したい... AWS側でAPIを追加しても良いのですが、なんと、Next.jsの中でAPIを実装出来ます ので、次節で触れていきたいと思います!
Router Handlersについて
Router Handlers は、Next.jsの中でAPIを実装できる機能であり、Page Routerの API Routes と同等のものです。こちらはサーバー側の機能であるためクライアントサイドのバンドルサイズが大きくなることはないとのことです。
appディレクトリの配下に任意のディレクトリを作成し、その下に route.tsx というファイルを作成します。こちらがAPIのエンドポイントになりまして、例えば api というディレクトリを作成し、その下にroute.tsを作成した場合、/api がエンドポイントになります。
習うより慣れろ、ということで実際に触ってみたいと思います。今回は、apiディレクトリの配下にusersディレクトリを作成し、 /api/users に対して GET リクエストを送信するとDynamoDBからデータを取得できるようにしたいと思います。
nextjs-project/
|- api/
| |- users/
| route.ts
import { NextResponse } from "next/server";
import AWS from "aws-sdk";
AWS.config.update({
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
region: process.env.AWS_REGION,
tableName: process.env.TABLE_NAME,
});
const dynamoDB = new AWS.DynamoDB.DocumentClient();
export const GET = async () => {
const params = {
TableName: tableName,
};
const result = await dynamoDB.scan(params).promise();
return NextResponse.json(result.Items);
};
route.tsの内容としては非常に単純で、利用ユーザー情報が格納されているDynamoDBテーブルからデータを全件scanで取得しています。参考までにテーブルの内容は以下になります。
今回はこの accountId をユーザー名として取り扱います。
これでRoute Handlerの実装は出来たので、adminページで呼び出せるように修正していきます。今回はfetchではなく axios を使ってみたいと思います。
+ import axios from "axios";
const getUsers = async () => {
- const response = await fetch("https://jsonplaceholder.typicode.com/users");
- const users: User[] = await response.json();
+ const response = await axios.get("http://localhost:3000/api/users");
console.log(response.data);
- return users;
+ return response.data;
};
...
export const Users = async () => {
....
{userData &&
userData.map((user) => (
- <li key={user.id} className="userItem">
- {user.name}
+ <li key={user.accountId} className="userItem">
+ <p>ユーザー名: {user.accountId}</p>
</li>
))}
...
adminページを開いてみると、DynamoDBから取得されたデータが問題なく表示されていることが確認出来ました!
また、ターミナルを確認すると、APIのレスポンスデータが表示されていることが分かります。
これで、フロントエンドからアクセス可能なAPIをNext.js内で作成することが出来ました!この機能はPage Router時代からありましたが、初めてNext.jsに触った身としてはかなり感動しました!
Server Actionsについて
もう一つServer Componentsの機能として、Server Actionsというものをご紹介できればと思います。こちらはNext.js のバージョン 13.4 で新たに追加された機能で、クライアント側のフォームの送信やボタン操作などのイベントから、サーバー側で実行される関数を呼び出せるようになります。こちらも実際に触っていきながらイメージを掴んでいきたいと思います。
前回の記事でChatGPTとの会話ページであるChatページとChatComponentを作成しましたが、今回はChatComponentをServer Components化したいと思います。参考までに、前回作成したChatComponentはこちらになります。
"use client";
import styles from "./chat_component.module.scss";
import PrivateRoute from "../PrivateRoute";
import React, { useState } from "react";
import { postChat } from "@/api/chatgpt";
import { useAuth } from "@/hooks/auth_hook";
interface ChatData {
actor: string;
content: string;
}
export const ChatComponent: React.FC = () => {
const { username, isLoading } = useAuth();
/** ユーザーが入力したメッセージのstate */
const [content, setContent] = useState<string>("");
/** 現在のセッションでの会話履歴のstate */
const [chatList, setChatList] = useState<ChatData[]>([]);
if (isLoading) return <h1>Loading...</h1>;
const onChangeContent = (event: React.ChangeEvent<HTMLInputElement>) =>
setContent(event.target.value);
/**
* ユーザー名とユーザーが入力したメッセージをパラメータとして、APIを呼び出す
* 会話履歴を表示するために、ユーザーのメッセージとAPIのレスポンスをchatListに追加する
*/
const postMessage = async () => {
const data = {
message: content,
sessionId: username,
};
/** ユーザーの入力した内容をchatListに追加 */
setChatList((prevChatList) => [
...prevChatList,
{ actor: "human", content: data.message },
]);
try {
const res = await postChat(data);
/** chatGPTのメッセージをchatListに追加 */
setChatList((prevChatList) => [
...prevChatList,
{ actor: "ai", content: res.data },
]);
} catch (error) {
console.log(error);
}
setContent("");
};
/** ユーザーのメッセージとChatGPTのメッセージでスタイルを変えている
* (某メッセージアプリをイメージ)
*/
return (
<PrivateRoute>
<div className="chatArea">
{chatList.map((item, index) => {
return (
<div className="chatContainer" key={index}>
<p
key={index}
className={
item.actor === "ai" ? "chatAi" : "chatHuman"
}
>
{item.content}
</p>
</div>
);
})}
</div>
<div className="postChat">
<input
id="postChatContent"
type="text"
placeholder="投稿内容"
value={content}
onChange={onChangeContent}
/>
<button id="postBtn" onClick={postChat}>
投稿
</button>
</div>
</PrivateRoute>
);
};
こちらをServer Components化出来ればと考えましたが、input要素の入力内容や投稿ボタンの動作などはブラウザの機能であり、Server Compontensでは扱うことが出来ないため諦めていました。そこで、Server Actions に出会いました。実際に実装してみましょう。
まず、Server Actionsを利用するために設定が必要ですので、そちらを実施します。next.config.jsを編集して、Server Actionsを有効化します。
/** @type {import('next').NextConfig} */
const nextConfig = {
+ experimental: {
+ serverActions: true,
+ },
};
module.exports = nextConfig;
既存のClient Componentsと比較するために、新しくchat_serverというページを作成することにします。
nextjs-project/
|- app/
| |- chat_server/
| | page.tsx
| | ChatClient.tsx
| | chat.module.css
...
|- components/
| |- ChatServerComponent/
| | index.tsx
| | header.module.css
肝となるChatServerComponentですが、まずは作成したコードをご覧ください。
import styles from "./chat_component.module.scss";
import { postChatLangChain } from "@/api/chatgpt";
import { revalidatePath } from "next/cache";
import { getCookie } from "@/utils/cookie";
/** 現在のセッションでの会話履歴のリスト */
let chatList = [] as ChatData[];
export const ChatServerComponent: React.FC = () => {
/** Cookieからログインユーザーのユーザー名を取得
* ※ 詳細な説明は省きます
*/
const userName = getCookie("LastAuthUser");
const postMessage = async (data: FormData) => {
"use server";
/** ユーザーの入力した内容をchatListに追加 */
chatList = [
...chatList,
{ actor: "human", content: data.get("message") as string },
];
/** APIリクエストの際のパラメータ */
const post_data = {
message: data.get("message") as string,
sessionId: userName,
};
try {
const res = await postChatLangChain(post_data);
console.log(res.data);
/** chatGPTのメッセージをchatListに追加 */
chatList = [...chatList, { actor: "ai", content: res.data }];
} catch (error) {
console.log(error);
}
revalidatePath("/chat_server");
};
return (
<>
<div className="chatArea">
{chatList.map((item, index) => {
return (
<div className="chatContainer" key={index}>
<p
key={index}
className={
item.actor === "ai" ? "chatAi" : "chatHuman"
}
>
{item.content}
</p>
</div>
);
})}
</div>
<form className="styles.postChat" action={postMessage}>
<label htmlFor="message" hidden />
<input
id={styles.post_chat_content}
type="text"
name="message"
placeholder="投稿内容"
/>
<button type="submit" id="postBtn">
投稿
</button>
</form>
</>
);
};
重要なポイントを見ていきたいと思います。まず、入力フォームの表示に関して、従来のReactであればonChange や onSubmit を利用することになるかと思いますが、Server Actionsでは form タグ と action props を利用してフォームを作成します。コードで言うところの以下の箇所になります。
+ <form className="postChat" action={postMessage}>
<label htmlFor="message" hidden />
<input
id={styles.post_chat_content}
type="text"
name="message"
placeholder="投稿内容"
/>
<button type="submit" id="postBtn">
投稿
</button>
+ </form>
formタグのaction propsには実行したい関数名が指定出来ますので、ユーザーの入力をChatGPTに送る関数である postMessage を指定しています。postMessage内にも重要な箇所があります。
const postMessage = async (data: FormData) => {
+ "use server";
chatList = [
...chatList,
{ actor: "human", content: data.get("message") as string },
];
const post_data = {
message: data.get("message") as string,
sessionId: userName,
};
try {
const res = await postChatLangChain(post_data);
chatList = [...chatList, { actor: "ai", content: res.data }];
} catch (error) {
console.log(error);
}
+ revalidatePath("/chat_server");
};
まず1つ目の "use server" ですが、Sever Actions では action props に指定した関数をサーバ側で実行させるために追加する必要があります。(無いとエラーになります)
また2つ目の revalidatePath("/chat_server") ですが、こちらはaction propsで指定した関数を実行した後に結果をブラウザに反映するために必要になります。
それでは、chat_serverページを作成して動作を確認してみます。
import styles from "./chat.module.scss";
import { ChatServerComponent } from "@/components/ChatServerComponent";
import { ChatClient } from "./ChatClient";
const ChatServer: React.FC = () => {
return (
<div className="container">
<h1 className="pageTitle">Chatページ(Server Component)</h1>
<ChatClient>
<ChatServerComponent />
</ChatClient>
</div>
);
};
export default ChatServer;
"use client";
import PrivateRoute from "@/components/PrivateRoute";
import { useAuth } from "@/hooks/auth_hook";
import React from "react";
interface Props {
children: React.ReactNode;
}
export const ChatClient: React.FC<Props> = (props) => {
const { children } = props;
const { isLoading } = useAuth();
if (isLoading) {
return <h1>Loading...</h1>;
}
return <PrivateRoute>{children}</PrivateRoute>;
};
動作確認をしてみます。Topページにchat_serverページに遷移するボタンを追加して(詳細は省略)、chat_serverページを開いてみます。
入力フォームが問題なく表示されています。では何かメッセージを送ってみたいと思います。
ちゃんと回答が返ってきました!ちなみにターミナル上を確認すると、APIのレスポンスが表示されており、サーバー側で実行されていることが分かります。
まとめ
今回はNext.js(App Router)の中核であるServer Componentsを本格的に体験してみました。本記事では紹介しきれていない機能もまだたくさんありますし、正直まだしっかりと理解できている気がしませんが、実際に手を動かしてみることで、Server Componentsの有用性等を体験出来たのではないかなと思っています。次回はいつになるか分かりませんが、今回触れることの出来なかったよりDeepなSever Componentsの世界に飛び込んでみたいと思っています!それでは、本記事を読んでいただきましてありがとうございました!