Next13からstableとなったAppRouterを使い、ServerComponentでバックエンドのRailsにAPIリクエストを送る処理を実装したいと思います。
今回、チーム開発でフロントエンドにNext.js14を導入することになりました。
なるべく公式に沿って実装しようということで、AppRouterのServerComponentをフル活用する運びとなりましたが、以前作った自分のPFも、PageRouterを参考にしたためかapp/page.tsxで'use client'を使用しています。ナンテコッタ。これではせっかくのAppRouterが…。
ということで、ServerComponentの使い方を調べつつ、バックエンドのRailsにAPIリクエストを送る実装を作ってみることにしました。
環境
Next.js 14.1.4
Rails 7.1.3
Docker
Server Componentをサラッとおさらい
このあたりは詳しく書かれた記事もたくさんあるので割愛
公式によると、
AppRouterのデフォルトではサーバーコンポーネントを使用します。これにより、追加の構成を行わずにサーバーレンダリングを自動的に実装できるようになり、必要に応じてクライアントコンポーネントの使用を選択できます。公式
サーバーコンポーネントのメリットはこちら
ということで具体的には、
・インタラクティブ機能とイベントリスナー(onClick、onChangeなど)を使う
・状態とライフサイクルの影響 (useState、useReducer、useEffectなど)を使用する
・ブラウザ専用 API を使用する
・状態、効果、またはブラウザ専用 API に依存するカスタム フックを使用する
・Reactクラスコンポーネントを使用する
これら以外ではServer Componentを使用するようにします。
バックエンド実装
まず、バックエンドのRailsでリクエストを受け取って処理する機能を簡単に実装します。
back $ docker-compose run --rm back bundle exec rails g scaffold test_post title:string
backディレクトリに移動し、scaffoldでファイルを作成し、rails db:migrate
します。
次にseedファイルを作成します。
Test_post.create!(
[
{ title: 'Ruby' },
{ title: 'Rails' },
{ title: 'Next.js' },
{ title: 'React' }
]
)
こちらをrails db:seed
します。
今回、getメソッドでindexにアクセスし、title一覧を獲得します。
controllerはindexのみ使用します。
class TestPostsController < ApplicationController
def index
@test_posts = TestPost.all
render json: @test_posts
end
end
フロントエンド実装
公式ドキュメントのData Fetchingのサンプルコードを見ると、
async function getData() {
const res = await fetch('https://api.example.com/...')
// The return value is *not* serialized
// You can return Date, Map, Set, etc.
if (!res.ok) {
// This will activate the closest `error.js` Error Boundary
throw new Error('Failed to fetch data')
}
return res.json()
}
export default async function Page() {
const data = await getData()
return <main></main>
}
app/page.tsxですべて完結されています…。しかし、作成するアプリではロジックとビューのコンポーネントを分割し、app/page.tsxで呼び出して使用したい。
ということで、fetchの部分をコンポーネントに切り出して作成することにしました。
interface Post {
title: string;
}
async function getTestPost() {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/test_posts`);
const posts: Post[] = await response.json();
return posts.map(post => post.title);
}
export default async function TestPostUI() {
const titles = await getTestPost()
return (
<div>
<h1>Post Titles</h1>
<ul>
{titles.map((title, index) => (
<li key={index}>{title}</li>
))}
</ul>
</div>
);
}
リクエスト先のURLの指定
ここで最初の問題は、リクエストを送るURLの部分です。
クライアント側からリクエストする際は、localhost:◯000/test_posts
を指定すればokだったのですが、今回サーバー側からサーバー側へのリクエスト(?)になるためかアクセスできませんでした。
【参考】
frontのNext.jsアプリケーションからbackのAPIにアクセスしたい場合、frontコンテナ内のコードでhttp://localhost:4000と指定しても、それはfrontコンテナ内を指してしまうため、backサービスには届きません。これはlocalhostが各コンテナにとって自分自身を指すため、別のコンテナ(この場合はbackコンテナ)にはアクセスできないからです。GPT
実際にlocalhostではなく、バックエンドのサービス名を使い、back:3000と指定するとうまくアクセスできました。また、docker-compose.ymlでポート番号をマウントしている場合、例えば
ports:
- "4000:3000"
この場合、Dockerの実際のポート番号は3000になるため、URLのサービス名にback:3000を指定します。
ビュー部分をapp/page.tsxで呼び出す
import TestPostUI from './_services/testPost';
export default function Home() {
return (
<>
<TestPostUI />
</>
);
}
これでうまく値を獲得し表示させることができました。
ちゃんとサーバーサイドでレンダリングできているか確認するために、TestPostUI
のtitlesをコンソールに出力してみます。
const titles = await getTestPost()
console.log(titles)
サーバー側のlogに値が出力され、ブラウザのconsoleには何も表示されませんでした。
ということで、この方法でサーバー側でfetchし、HTMLの生成もサーバー側で行うことに成功しました。パチパチ!
うまくいかなかったビューの切り分け
でもまてまて、本当は、ロジック部分とビュー部分も別コンポーネントで管理したいところです。
ということで、APIコンポーネントからビューの部分を切り出し、fetchしたデータをpuropsでUIに渡すようにしてみました。
interface Post {
title: string;
}
export default async function getTestPost() {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/test_posts`);
const posts: Post[] = await response.json();
return posts.map(post => post.title);
}
interface TestPostUIProps {
titles: string[];
}
export default function TestPostUI({ titles }: TestPostUIProps) {
return (
<div>
<h1>Post Titles</h1>
<ul>
{titles.map((title, index) => (
<li key={index}>{title}</li>
))}
</ul>
</div>
);
}
こちらは結果うまく動かすことができませんでした。
TypeError: Cannot read properties of undefined (reading 'map')at TestPostUI
これはtitlesの値がpropsで渡ってきておらず、map関数を呼び出す際にtitlesがundefined
であるとエラーになっています。logを確認すると、そもそもSQLも発行されていません。
ここで詰まって色々ゴニョゴニョしたところ、fetch関数が発火してない?ような感じで、'useEffect'を挟むとちゃんと取れてくる…デモクライアントレンダリングになっちゃう…
ということで、まあ公式もロジックとビューを同じファイルで書いてるし、一旦諦めて(笑)先程のコードで提出することになりました。
TypeScriptのエラー
さて、ちゃんとデータを獲得できてほっとしたのもつかの間、app/page.tsxが真っ赤になっています。
もう勘弁してくり。
<TestPostUI />
の呼び出し部分に以下の型エラーが発生しています。
'TestPostUI' を JSX コンポーネントとして使用することはできません。
その戻り値の型 'Promise<Element>' は、有効な JSX 要素ではありません。
調べてみると、Next13の時点では確認されているエラーのようで、TypeScriptの型との互換性に起因しているようでした。
しかし、現在はNext14で、この記事も1年以上前のものです。まだ残ってるのか?そんな馬鹿な?
To use async/await in a Server Component with TypeScript, you'll need to use TypeScript 5.1.3 or higher and @types/react 18.2.8 or higher.
現在の公式にはこのようにあるので、原因はここかも?
この問題はまだ未解決ですが一旦参考記事にあるようにエラーを逃がす方法を採用しました。
import TestPostUI from './_services/testPost';
export default function Home() {
return (
<>
{/* @ts-expect-error Server Component */}
<TestPostUI />
</>
);
}
もっとスッキリ解決できたら更新したいと思います。
最後に
最後までお読みいただきありがとうございます。
忘れないうちに勢いでまとめたので至らない点が多いかと思います。
ご指摘おまちしております。