はじめに
こんにちは!WEBエンジニア転職を目指しているK.Yです!
今回は、ReactアプリをNext.jsへ移行してみました!
JavascriptとReactで簡易的なブログ作った記事もありますので、そちらもよかったらご覧ください!
前提
ルーターは、App Router
CSSは、tailwindCSS
バージョン
react: 18.3.1,
react-dom: 18.3.1,
react-router-dom: 6.23.1
JavaScript ES6以降
Next.js
Next.jsとは、Reactをベースにしたウェブアプリケーションフレームワークで、
サーバサイドレンダリング(SSR)と静的サイト生成(SSG)を簡単に実現するためのツールです。
これによって、WEBアプリケーションのパフォーマンスが向上し、SEO対策ができます。
Next.jsでは、ページごとにJavaScriptファイルを作成し、そのファイルが自動的にルーティングされます。
また、APIルートを定義することで、バックエンド機能も簡単に追加できます。
App Router
今回は、App Routerの方でNext.jsに移行してみました。
Next.jsには、2種類のルーターがあります。
・Pages Router(pages)
・App Router(app)
App Routerとは、フォルダーベースのルーターで,Next.jsの新しいルーティングシステムです。
従来のpages
ディレクトリーと代わって、app
ディレクトリーを使用します。
大規模なアプリケーションや複雑なルーティング構造に適しています。
公式ドキュメントでもApp Router推奨と謳っています!
App Routerの基本的な使い方
app
ディレクトリー内にページを作成すると、そのディレクトリー構造がそのままURLパスに反映されます。
例えば、src/app/inquiry/page.tsx
と作成すると、inquiry
というURLが表示されます!
因みに、page.tsx
というのは、Next.jsが特定のファイル命名規則となっていて、
公式ドキュメントでも採用されている。
ディレクトリー構造(App Router)
app
内に、ブログの各ページをApp Routerで作ったディレクトリー構造となります!
src/app
├── _components/
│ ├── PostsList.tsx
│ ├── DetailsPage.tsx
│ └── InquiryPage.tsx
├── _data/
│ └── posts.ts
├── _styles/
│ └── globals.css
├── details/
│ └── [id]/
│ └── page.tsx
├── inquiry/
│ └── page.tsx
├── layout.tsx
└── page.tsx
・src/app/_components
複数のページで再利用されるコンポーネントが含まれます。
・src/app/_data/posts.ts
データの管理専用のディレクトリに分けることで、データの取得や管理が簡単になります。
・src/app/_styles/globals.css
全体のスタイルを管理します。
layout.tsxや各ページにインポートするとことで、全ページに共通のスタイルが適用できる。
・src/app/details/[id]/page.tsx (記事詳細ページ)
動的ルーティング:
角括弧([]
)を使って動的なURLパラメータを指定します。例えば、/details/123
のようなURLに対応します。
データフェッチ:
URLパラメータに基づいてデータを取得し、表示することが一般的です。
・src/app/inquiry/page.tsx(お問い合わせフォーム)
静的ルーティング:
固定されたURLパスに対応するページです。例えば、/inquiry
というURLにアクセスするとこのページが表示されます。
・src/app/page.tsx(記事一覧ページ)
アプリケーションのルートページ(トップページ)に対応します。
page.tsxは、アプリケーションの最初のエントリーポイントとして機能します!
・src/app/layout.tsx
全てのページに共通するレイアウトを定義するために使用。
記事一覧ページ
"use client";
import PostsList from './_components/PostsList';
const PostListPage: React.FC = () => {
return (
<PostsList />
);
};
export default PostListPage;
・src/app/page.tsx(記事一覧ページ)
use client
は、先頭に入れることで、そのファイルはクライアントサイドで,
実行される(ブラウザ上で実行)ことを明示するため。
宣言することで、パフォーマンスの最適化とコードの明確な区別が可能になります。
上記のコードは、記事のトップページである、PostsList
コンポーネントを表示する
ためだけのページとなります!
以下、PostsList
コンポーネント(src/app/_components/PostsList.tsx)
src/app/_components/PostsList.tsx(記事一覧ページ)
"use client";
import React from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import "@/app/_styles/globals.css"
type ArticleType = {
id: number;
createdAt: string;
categories: string[];
title: string;
content: string;
};
type PostsType = {
posts: ArticleType[];
};
const PostsList: React.FC = () => {
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "numeric",
day: "numeric",
};
return date.toLocaleDateString("ja-JP", options);
};
const [posts, setPosts] = useState<PostsType>({ posts: [] });
const [loading, setLoading] = useState<boolean>(true); // ローディング状態を追加
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch(
"https://1hmfpsvto6.execute-api.ap-northeast-1.amazonaws.com/dev/posts"
);
const data = (await response.json()) as PostsType;
setPosts(data);
} finally {
setLoading(false); // データ取得が完了したらローディングを終了
}
};
fetchData();
}, []);
if (loading) {
return <div>Loading...</div>; // ローディング中の表示
}
return (
<div>
<header className="bg-neutral-800 flex justify-between py-6 px-7 h-15">
<Link href="/" className="text-lg font-bold text-white no-underline">
Blog
</Link>
<Link
href="/inquiry"
className="text-base font-bold text-white no-underline"
>
お問い合わせ
</Link>
</header>
{Array.isArray(posts.posts) &&
posts.posts.map((article) => (
<div
key={article.id}
className="my-8 m-80 px-6 py-3 text-[16px] border-solid border-[1.8px] border-base-700"
>
<ul>
<li className="p-[10px]">
<Link href={`/details/${article.id}`}>
<div className="flex items-center justify-between">
<div className="text-xs text-neutral-500">
{formatDate(article.createdAt)}
</div>
<div className="self-start text-right mx-10">
{article.categories.map((category, idx) => (
<span
key={idx}
className="m-[6px] p-[6px] text-[13px] border border-solid border-blue-600 rounded text-blue-600"
>
{category}
</span>
))}
</div>
</div>
<div className="text-[25px]">{article.title}</div>
<div
className="mt-2 pt-2 overflow-hidden"
style={{
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2,
}}
dangerouslySetInnerHTML={{ __html: article.content }}
></div>
</Link>
</li>
</ul>
</div>
))}
</div>
);
};
export default PostsList;
・import Link from "next/link";
Next.jsでは、Linkコンポーネントはreact-router-dom
からインポートするのではなく、next/link
からインポートします!
記事詳細ページ
src/app/details/[id]/page.tsx(詳細ページ)
"use client";
import DetailsPage from "@/app/_components/DetailsPage";
import { useParams } from "next/navigation";
const DetailsArticle = () => {
const {id} = useParams();
if(!id) {
return <div>Loading...</div>
}
return <DetailsPage id={id} />
};
export default DetailsArticle;
・src/app/details/[id]/page.tsx(詳細ページ)
このファイルは、動的な詳細ページを定義しています。
[id]
はプレースホルダーで、実際のURLパラメーターがここに入ります。
例えば、http://localhost:3000/details/1
にアクセルすると、page.tsx
が表示され、id
が1
の詳細情報が表示されます。
これは、動的ルーティング
でサポートされていて、特定のアイテムやエントリの詳細を表示させれるために使用されます。
・import { useParams } from "next/navigation";
useParams
フックは、next/navigation
からインポートされていて、
現在のURLパラメータを取得するために使用しています。
・const {id} = useParams();
useParams
フックを使ってURLからid
パラメータを取得。
・return <DetailsPage id={id} />;
DetailsPage
コンポーネントをレンダリングし、そのid
プロパティに取得したid
パラメータを渡しています。これにより、DetailsPage
コンポーネントが特定のIDに基づいた詳細情報を表示。
以下、DetailsPageコンポーネント(src/app/_components/DetailsPage.tsx)
src/app/_components/DetailsPage.tsx(記事詳細ページ)
"use client";
import React from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import "@/app/_styles/globals.css";
type detailsType = {
id: number;
createdAt: string;
thumbnailUrl: any;
categories: string[];
title: string;
content: string;
};
type ApiResponse = {
post: detailsType;
};
interface DetailsPageProps {
id: string | string[];
}
const DetailsPage: React.FC<DetailsPageProps> = ({ id }) => {
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "numeric",
day: "numeric",
};
return date.toLocaleDateString("ja-JP", options);
};
const [detailsData, setDetailsData] = useState<detailsType>();
const [loading, setLoading] = useState<boolean>(true); // ローディング状態を追加
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch(
`https://1hmfpsvto6.execute-api.ap-northeast-1.amazonaws.com/dev/posts/${id}`
);
const result = (await response.json()) as ApiResponse;
setDetailsData(result.post);
} finally {
setLoading(false); // データ取得が完了したらローディングを終了
}
};
fetchData();
}, [id]);
if (loading) {
return <div>Loading...</div>; // ローディング中の表示
}
if (!detailsData) return <div>投稿が見つかりません</div>;
return (
<div>
<header className="bg-neutral-800 flex justify-between py-6 px-7 h-15">
<Link href="/" className="text-lg font-bold text-white no-underline">
Blog
</Link>
<Link
href="/inquiry"
className="text-lg font-bold text-white no-underline"
>
お問い合わせ
</Link>
</header>
<div
style={{ border: "none" }}
className="my-10 m-80 px-6 py-3 border-solid border-[13px] border-base-700"
>
<ul>
<li key={detailsData.id}>
<div className="img w-[800px] h-[400px] relative">
<Image
src={detailsData.thumbnailUrl}
alt="img"
layout="fill"
objectFit="cover"
/>
</div>
<div className="pt-5 flex items-center justify-between">
<div className="text-xs border-gray-400">
{formatDate(detailsData.createdAt)}
</div>
<div className="self-start text-right mx-10">
{detailsData.categories.map((category, idx) => (
<span
key={idx}
className="m-[6px] p-[6px] text-[13px] border border-solid border-blue-600 rounded text-blue-600"
>
{category}
</span>
))}
</div>
</div>
<div className="text-[25px] mt-2">{detailsData.title}</div>
<div
className="block mt-2 text-[16px]"
dangerouslySetInnerHTML={{ __html: detailsData.content }}
></div>
</li>
</ul>
</div>
</div>
);
};
export default DetailsPage;
・interface DetailsPageProps { id: string | string[]; }
DetailsPageコンポーネントが受け取るプロパティ(props)を定義しています。
・id: string | string[];
id
プロパティは、文字列または文字列の配列を表しています。
・const DetailsPage: React.FC<DetailsPageProps> = ({ id }) => { ... }
<DetailsPageProps>
は、このコンポーネントがDetailsPageProps型のプロパティを受け取ることを示しています。
({ id }) => { ... }
アロー関数で、id
プロパティを受け取り、その値を使って以降の処理を行います。
お問い合わせフォーム
src/app/inquiry/page.tsx(問い合わせフォーム)
"use client";
import InquiryPage from "@/app/_components/InquiryPage";
const Inquiry = () => {
return <InquiryPage/>
}
export default Inquiry;
上記のコードは、静的なお問い合わせフォームを定義しています。
静的ルーティング
では、app/inquiry/page.tsx
ファイルが自動的に/inquiry
というURLに対応します。Next.jsはこのファイル構造を元にルートを生成し、問い合わせフォームページを表示します。これにより、手動でルーティングを設定する手間が省けます。
以下のコードは、InquiryPageコンポーネント(src/app/_components/InquiryPage.tsx)
src/app/_components/InquiryPage.tsx(お問い合わせフォーム)
"use client";
import React from "react";
import Link from "next/link";
import { FormEvent, useState } from "react";
import "@/app/_styles/globals.css";
type InquiryType = {
name: string;
email: string;
message: string;
};
type ErrorsType = {
name?: string;
email?: string;
message?: string;
};
const InquiryPage: React.FC = () => {
const [inquiryData, setInquiryData] = useState<InquiryType>({
name: "",
email: "",
message: "",
});
const [errors, setErrors] = useState<ErrorsType>({});
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { id, value } = e.target;
setInquiryData((prevData) => ({ ...prevData, [id]: value }));
};
const validate = () => {
const tempErrors: ErrorsType = {};
if (!inquiryData.name) tempErrors.name = "お名前は必須です。";
if (!inquiryData.email) tempErrors.email = "メールアドレスは必須です。";
if (!inquiryData.message) tempErrors.message = "本文は必須です。";
setErrors(tempErrors);
return Object.keys(tempErrors).length === 0;
};
const handleSubmit = async (e: FormEvent): Promise<void> => {
e.preventDefault();
if (!validate()) return;
setIsSubmitting(true);
try {
const response = await fetch(
"https://1hmfpsvto6.execute-api.ap-northeast-1.amazonaws.com/dev/contacts",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(inquiryData),
}
);
if (!response.ok) throw new Error("Network response was not ok");
alert("送信しました");
setInquiryData({ name: "", email: "", message: "" });
setErrors({});
} catch (error) {
console.error("Error submitting form:", error);
} finally {
setIsSubmitting(false);
}
};
const handleClear = () => {
setInquiryData({ name: "", email: "", message: "" });
};
return (
<div>
<header className="bg-neutral-800 flex justify-between py-6 px-7 h-15">
<Link href="/" className="text-lg font-bold text-white no-underline">
Blog
</Link>
<Link
href="/inquiry"
className="text-lg font-bold text-white no-underline"
>
お問い合わせ
</Link>
</header>
<div className="w-[800px] mx-auto my-5 p-5">
<h1 className="text-xl mb-5 font-bold ">問合わせフォーム</h1>
<form id="myForm" onSubmit={handleSubmit}>
<div className="mb-4">
<label>
<dl>
<dt className="float-left w-[120px] mt-4">
お名前
</dt>
<div className="flex flex-col mb-5">
<dd>
<input
type="text"
id="name"
maxLength={30 as number}
value={inquiryData.name}
onChange={handleChange}
disabled={isSubmitting}
className="h-15 float-left"
/>
</dd>
{errors.name && <span>{errors.name}</span>}
</div>
</dl>
</label>
<div className="mb-4">
<label>
<dl>
<dt className="float-left w-[120px] mt-4 ">メールアドレス</dt>
<div className="flex flex-col">
<dd>
<input
type="text"
id="email"
value={inquiryData.email}
onChange={handleChange}
disabled={isSubmitting}
className="h-15 pl-5"
/>
</dd>
{errors.email && <span>{errors.email}</span>}
</div>
</dl>
</label>
</div>
<div className="mb-4">
<label>
<dl>
<dt className="float-left w-[120px] mt-[120px]">本文</dt>
<div className="flex flex-col">
<dd>
<textarea
id="message"
maxLength={500 as number}
value={inquiryData.message}
onChange={handleChange}
disabled={isSubmitting}
rows={10 as number}
className="w-full box-border border border-gray-300 rounded-lg mb-2.5 p-5"
/>
</dd>
{errors.message && <span>{errors.message}</span>}
</div>
</dl>
</label>
</div>
</div>
<div className="text-center mt-10">
<input
type="submit"
value="送信"
disabled={isSubmitting}
className="border border-gray-300
rounded m-0 mx-2 p-2 px-4
text-base font-bold
bg-blue-800 text-white"
/>
<input
type="reset"
value="クリア"
onClick={handleClear}
disabled={isSubmitting}
className="border border-gray-300
rounded m-0 mx-2 p-2 px-4
text-base font-bold
bg-gray-300"
/>
</div>
</form>
</div>
</div>
);
};
export default InquiryPage;
ポイント
Next.js
フレームワーク
Next.jsは、Reactペースのフレームワーク。
レンダリング
サーバーサイドレンダリング(SSR)と静的サイト生成(SSG)をサポート。
パフォーマンス
高速なページロードと優れたSEO対策。
App Router
新しいルーティングシステム
pages
ディレクトリに代わり、app
ディレクトリを使用。
ファイル構造
ディレクトリ構造がそのままURLパスに反映。
動的ルーティング
[id]のようなプレースホルダーで動的URLをサポート。
レイアウト
layout.tsxで共通レイアウトを簡単に設定。
クライアント実行
use client"でクライアントサイド実行を明示。
おわり
Next.jsは、ディレクトリ構造に基づいてURLパスが自動生成されるため、
手動でのルート設定の手間が省けます!
とても便利ですし、開発効率が上がり、コードの可読性や保守性が高まります!