Next.jsのダイナミックルーティングでフェッチできません
解決したいこと
Next.jsのダイナミックルーティングでどうしてもフェッチができません。何が問題でしょうか。
現在、Next.jsとRails APIサーバーを連携して開発を行っています。具体的には、Next.jsの動的ルートからRails APIサーバーにリクエストを送信してデータを取得し、ページに表示する機能を実装しています。
取り組み中の機能は一般的なタイトル付きのポスト投稿機能です。
これまでデータの取得自体は問題なくできていたのですが、ダイナミックルーティングを使用したところデータが取得できなくなってしまいました。
Railsログは反応なしの状態で、GPT4oに聞いてもAPIエンドポイントが存在していないのではという回答しか得られず、Web検索もAppRouterを使用しているので、これらのエラーについての記事が若干少なく、前に進められていません。
SSRを使用してデータを取得したいと思っています。(それ以前にデータフェッチができなくて四苦八苦しています)
githubのリンクは以下です。コードはfeatureブランチにプッシュしてあります。
自分で試したこと
・GPT4oに解決策を聞く
・docker-compose downからの再build,再up
・CORSの設定確認
・ログインクッキーの取得
・Railsログの確認(何も出力されない)
・console.logの設置と確認(何も出力されない)
・pram.idの確認(値は取れてきていることを確認)
・ThunderClientを使用してAPIエンドポイントにGetリクエストを送信(成功)
・http://localhost:3000/api/v1/posts/72やhttp://localhost:3000/api/v1/postsを普通に開く(アクセス成功)
・複数の動画教材によるダイナミックルーティングの復習
docker-compose logsは以下のようなログを出力しています。
frontend-1 | Error fetching post 71: 404
frontend-1 | GET /api/v1/posts/71 404 in 110ms
frontend-1 | GET /diarys/71 200 in 292ms
frontend-1 | Error fetching posts: 404
frontend-1 | Error: Failed to fetch posts: 404
frontend-1 | at Object.generateStaticParams (webpack-internal:///(rsc)/./app/diarys/[id]/page.jsx:28:19)
frontend-1 | at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
frontend-1 | at async buildParams (/usr/src/app/node_modules/next/dist/build/utils.js:1026:40)
frontend-1 | at async /usr/src/app/node_modules/next/dist/build/utils.js:1043:33
frontend-1 | at async Object.loadStaticPaths (/usr/src/app/node_modules/next/dist/server/dev/static-paths-worker.js:47:16)
frontend-1 | GET /api/v1/posts 404 in 50ms
post関連のディレクトリ構成は以下です。
my-app/
├── app/
└── diarys/
└── [id]/
└── page.jsx
└── components/
└── PostInput.jsx
└── PostView.jsx
└── page.jsx
【開発環境の情報】
・MacOS
・Apple M1
・メモリ16GB
・ruby "3.3.0"
・rails "7.0.4.3"
・mySQL8.0.36(gem "mysql2","~> 0.5.6")
・"react": "^18", (画面遷移はAppRouterを使用しています)
該当するソースコード
// app/diarys/[id]/page.jsx
// 特定のポストの詳細情報を取得する関数
const fetchPostById = async (id) => {
const res = await fetch(`http://localhost:3000/api/v1/posts/${id}`, {
withCredentials: true,
});
if (!res.ok) {
console.error(`Error fetching post ${id}: ${res.status}`);
throw new Error(`Failed to fetch post: ${res.status}`);
}
const post = await res.json();
return post;
};
// 動的パラメータを生成する関数
export async function generateStaticParams() {
try {
const res = await fetch("http://localhost:3000/api/v1/posts");
if (!res.ok) {
console.error(`Error fetching posts: ${res.status}`);
throw new Error(`Failed to fetch posts: ${res.status}`);
}
const posts = await res.json();
return posts.map((post) => ({ id: post.id.toString() }));
} catch (error) {
console.error(error);
return []; // エラー発生時は空の配列を返す
}
}
// ページコンポーネントの実装
export default async function PostPage({ params }) {
try {
const post = await fetchPostById(params.id);
return (
<div>
<h1>{post.title}</h1>
<p>{post.content}</p>
</div>
);
} catch (error) {
return (
<div>
<h1>Error: {error.message}</h1>
</div>
);
}
}
Rails.application.routes.draw do
get 'users/new'
namespace :api, format: 'json' do
namespace :v1 do
resources :users do
member do
get :following, :followers
end
end
get 'current_user', to: 'users#current_user_info'
get 'posts_user', to: 'users#posts_user_info'
resources :sessions, only: [:create]
delete '/logout', to: 'sessions#destroy'
# resources :account_activations, only: [:edit]
resources :posts, only: %i[index show create update destroy]
resources :password_resets, only: %i[create edit update]
resources :relationships, only: %i[create destroy]
end
end
get 'up' => 'rails/health#show', as: :rails_health_check
end
module Api
module V1
class PostsController < ApplicationController
include ActionController::Cookies
include SessionsHelper
before_action :logged_in_user, only: %i[create destroy update]
before_action :correct_user, only: %i[destroy update]
def index
@posts = Post.all
render json: @posts
end
def show
@post = Post.find(params[:id])
render json: @post
end
def create
@post = current_user.posts.build(post_params)
if @post.save
message = [I18n.t('posts.create.flash.success')]
render json: { status: 'success', message: message }, status: :created
else
message = @post.errors.full_messages
render json: { status: 'failure', message: message }, status: :unprocessable_entity
end
end
def update
@post = Post.find(params[:id])
if @post.update(post_params)
render json: @post
else
render json: @post.errors, status: :unprocessable_entity
end
end
def destroy
@post = Post.find(params[:id])
@post.destroy
Rails.logger.info "ポストが削除されました。ユーザーID: #{current_user.id}"
render json: { status: 'success', message: 'ポストが削除されました' }
end
private
def post_params
params.require(:post).permit(:title, :content )
end
def correct_user
@post = current_user.posts.find_by(id: params[:id])
render json: { error: '許可されていません' }, status: :forbidden if @post.nil?
end
def logged_in_user
unless logged_in?
Rails.logger.info '未ログインユーザーがログインが必要なページにアクセスしようとしました。'
render json: { status: 'notLoggedIn', message: 'ログインしてください' }, status: :unauthorized
end
end
end
end
end
ちなみに以下のファイルではデータの取得ができています
import { useEffect, useState } from "react";
import axios from "axios";
import { format } from "date-fns";
import ja from "date-fns/locale/ja";
import Link from "next/link";
const PostView = ({ reload }) => {
const [users, setUsers] = useState([]);
const [posts, setPosts] = useState([]);
const [editingPost, setEditingPost] = useState(null);
const [newContent, setNewContent] = useState("");
const [newTitle, setNewTitle] = useState("");
useEffect(() => {
const fetchUsersAndPosts = async () => {
try {
const [userRes, postRes] = await Promise.all([
axios.get("http://localhost:3000/api/v1/posts_user", {
withCredentials: true,
}),
axios.get("http://localhost:3000/api/v1/posts", {
withCredentials: true,
}),
]);
const usersWithCurrentUserId = userRes.data.users.map((user) => ({
...user,
current_user_id: userRes.data.current_user.id,
}));
setUsers(usersWithCurrentUserId);
setPosts(postRes.data);
} catch (error) {
console.error("データの取得に失敗しました:", error);
}
};
fetchUsersAndPosts();
}, [reload]);
const handleDelete = async (postId) => {
try {
await axios.delete(`http://localhost:3000/api/v1/posts/${postId}`, {
withCredentials: true,
});
setPosts(posts.filter((post) => post.id !== postId));
} catch (error) {
console.error("ポストの削除に失敗しました:", error);
}
};
const handleEditClick = (post) => {
setEditingPost(post.id);
setNewContent(post.content);
setNewTitle(post.title);
};
const handleSave = async (postId) => {
try {
const response = await axios.patch(
`http://localhost:3000/api/v1/posts/${postId}`,
{ title: newTitle, content: newContent },
{
withCredentials: true,
}
);
setPosts(
posts.map((post) =>
post.id === postId
? {
...post,
title: response.data.title,
content: response.data.content,
}
: post
)
);
setEditingPost(null);
} catch (error) {
console.error("ポストの編集に失敗しました:", error);
}
};
if (!users.length || !posts.length) {
return <div>Loading...</div>; // データがロード中の場合の表示
}
return (
<div className="max-w-2xl mx-auto p-4">
{posts.map((post) => {
const user = users.find((user) => user.id === post.user_id);
const formattedDate = format(
new Date(post.created_at),
"yyyy/M/d HH:mm",
{ locale: ja }
);
return (
<div key={post.id} className="border-b border-gray-200 py-4">
<div className="flex items-center mb-2">
<div className="w-10 h-10 bg-gray-200 rounded-full mr-4 flex-shrink-0"></div>
<div>
<p className="text-lg font-semibold">{user.name}</p>
<p className="text-sm text-gray-500">{formattedDate}</p>
</div>
</div>
{editingPost === post.id ? (
<div className="space-y-2">
<label className="block">
<span className="text-gray-700">タイトル:</span>
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
className="w-full px-3 py-2 border rounded-md"
/>
</label>
<label className="block">
<span className="text-gray-700">投稿:</span>
<input
type="text"
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
className="w-full px-3 py-2 border rounded-md"
/>
</label>
<div className="space-x-2">
<button
onClick={() => handleSave(post.id)}
className="px-4 py-2 bg-blue-500 text-white rounded-md"
>
保存
</button>
<button
onClick={() => setEditingPost(null)}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md"
>
キャンセル
</button>
</div>
</div>
) : (
<div className="ml-14">
<Link href={`/diarys/${post.id}`}>
<p className="mb-2 text-gray-800 text-xl font-bold">
{post.title}
</p>
</Link>
<p className="mb-4 text-gray-600">
{post.content.length > 40
? `${post.content.slice(0, 430)}...`
: post.content}
</p>
<div className="space-x-2">
{post.user_id === user?.current_user_id && (
<>
<button
onClick={() => handleEditClick(post)}
className="px-4 py-2 bg-yellow-500 text-white rounded-md"
>
編集
</button>
<button
onClick={() => handleDelete(post.id)}
className="px-4 py-2 bg-red-500 text-white rounded-md"
>
削除
</button>
</>
)}
</div>
</div>
)}
</div>
);
})}
</div>
);
};
export default PostView;
どうかよろしくお願いいたします。必要な情報があればコメントお願いいたします。