はじめに
フロントエンドエンジニア2年目の @Mk459 と申します。Next.jsがバージョン13で新しくなりたくさんの機能が増えたものの全くキャッチアップできていなかったので、主要機能である
- App router
- Parallel Routes
- Intercepting Routes
を触りつつ、ポートフォリオサイトのテンプレートのようなものを作ってみました。ポートフォリオサイトの中に「Works」ページがあり、その中に作品一覧が並んでいて、その作品をクリックすると作品の詳細を見ることができます。
本記事の内容は、公式ドキュメントと公式のサンプルプロジェクトNextgramを踏襲しています。Next.js 13は現状ドキュメントも簡素でチュートリアルもないので、ドキュメントやコードを読むだけではあまり理解できない自分のような初心者でも手順を追って作っていけるように記事を構成しました。(特にモーダル周りの実装について重点的に触れています)
手順
プロジェクトを作る
まずはcreate-next-app
コマンドで新しいプロジェクトを作成します。
npx create-next-app@latest
そのあといくつか質問されますが、今回は以下のように回答しました。App Routerを使うか?の項目ではYesを選ぶのを忘れないようにします。
What is your project named? my-app
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like to use `src/` directory? Yes
Would you like to use App Router? (recommended) Yes
Would you like to customize the default import alias? No
すると以下のようなフォルダ構成のディレクトリが作成されます。
以下のコマンドで実行すると…
npm run dev
最初のページが表示できました!
ページを作る
新しくページを作る
仕組みはとてもシンプルで、appディレクトリ以下にディレクトリ名/page.tsx
を作成すると、ディレクトリ名がそのままページになります。
最初はappディレクトリ直下にapp/page.tsx
があり、これが一番上のページ(localhost:3000
)になります。
app/works/page.tsx
を作ると、localhost:3000/works
でアクセスできるようになります。
レイアウトの仕組みを知る
初期ページは、app/page.tsx
とapp/layout.tsx
の2つのファイルで構成されています。
layout.tsx
は、その名の通りレイアウトを作るためのファイルです。テンプレートのようなものです。app/layout.tsx
は一番上位なルートレイアウトとなり、全てのページ(ルート)に適用されるものになります。
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
bodyタグの中に{children}
があります。app/page.tsx
はapp/layout.tsx
が適用されるので、page.tsx(でreturnされるJSX)がchildrenというpropsを通して挿入されます。
page.tsxは以下のように書かれているので、
export default function Home() {
return (
<main>
<div>
<p>
Get started by editing
</p>
・・・
{children}
に挿入されると実際にはこのような構造になります。
<html lang="en">
<body>
<main>
<div>
<p>Get started by editing </p>
...
</body>
</html>
layout.tsxはappディレクトリ直下だけではなくpage.tsxと同じ階層に配置することができ、配置するとそのパス以下のファイル全てにそのレイアウトが適用されます。
従来はコンポーネントを組み合わせてレイアウトを組んでいくことが多かったですが、Next.js13ではこのようにlayout.tsxというレイアウト専用のファイルが用意され、複数ページに渡る共通レイアウトが実装しやすくなりました。
スタイリングする
レイアウトの仕組みがわかったところで、app/layout.tsx
とapp/works/layout.tsx
のスタイルを調整していきます。
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={PTSerif400.className}>
<body>
<header className="text-[4rem] h-60 bg-white flex relative">
<h1 className="left-[4rem] bottom-[2rem] absolute">Portfolio Page</h1>
</header>
{children}
</body>
</html>
);
}
export default function WorksLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<main className="bg-gray-200 h-screen px-[4rem] py-[2rem]">
<h2 className="text-[3rem] w-[30rem] border-b border-solid border-gray-400 mb-[2rem]">
Works
</h2>
{children}
</main>
);
}
モーダルを作る
カードコンポーネントを作る
作品一覧ページの外枠ができたので、中身を作っていきます。各作品の画像・作品名を表示する部分をCardコンポーネントと呼ぶことにします。
まずはapp/data.ts
に、必要なデータを定義します。もちろんDBと接続してAPIで取得しても良いのですが、ここでは省略してローカルにデータを置いておくことにします。
export type Work = {
id: string;
title: string;
imageSrc: string;
};
const data: Work[] = [
{
id: "1",
title: "Work 01",
imageSrc: "/01.png",
},
{
id: "2",
title: "Work 02",
imageSrc: "/02.png",
},
];
export default data;
このデータを表示するためのCardコンポーネントをapp/works/components/Card/Card.tsx
として作成し、
import Link from "next/link";
import Image from "next/image";
export default function Card({
id,
title,
imageSrc,
}: {
id: string;
title: string;
imageSrc: string;
}) {
return (
<Link href={`/works/${id}`}>
<div className="bg-white px-5 pt-5 pb-5 rounded-lg">
<Image
alt=""
src={imageSrc}
height={300}
width={400}
className="w-full object-cover mb-5"
/>
<h2 className="text-[1.5rem]">{title}</h2>
</div>
</Link>
);
}
app/works/page.tsx
でCardコンポーネントにdata.tsのデータを渡します。
import swagData from "../data";
import Card from "./components/Card/Card";
export default function Works() {
const data = swagData;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-3 auto-rows-max gap-6 m-10">
{data.map(({ id, title, imageSrc }) => (
<Card key={id} id={id} title={title} imageSrc={imageSrc}></Card>
))}
</div>
);
}
このように画像と作品名が表示されたらOKです!
通常の遷移先のページを作る
今の時点では、カードコンポーネントのLinkタグに設定したURL(/works/${id}
)が存在しないので、カードコンポーネントをクリックしても404ページに飛んでしまいます。
そこで、クリックしたら開く各作品ごとのページを、Next.js 12以前にもあったDynamic Routesを使って作っていきます。
今回は、各作品ごとに割り当てられているidをkeyにして、works/1
、works/2
のようなページを動的に作りたいとします。この場合、worksの下に[id]
というフォルダを新しく作り、その中のページコンポーネントで引数として渡すparamsパラメーターに、idを設定します。
import CardDetail from "../components/CardDetail/CardDetail";
import swagData, { Work } from "../../data";
export default function WorkPage({
params: { id },
}: {
params: { id: string };
}) {
// データ全体から、idがURLと一致するデータを返す
const data: Work = swagData.find((p) => p.id === id)!;
return (
<div className="w-8/12 container mx-auto my-10">
<CardDetail data={data} />
</div>
);
}
このページは今の時点ではCardコンポーネントをクリックすると必ず遷移することになるページですが、モーダルが完成するとworks
からworks/1
に遷移する時ではなくworks/1
のURLに直接アクセスしたときに表示されるページになります。
(この後の手順で、Cardコンポーネントをクリックした時表示させるものをモーダルに置き換えます)
ここではモーダルとして表示させたい内容をCardDetailコンポーネントとして作っておいて、モーダルではなくページに埋め込むことにしました。
import Image from "next/image";
import { Work } from "../../../data";
export default function CardDetail({ data }: { data: Work }) {
return (
<div className="p-10 w-50 bg-white rounded-lg flex flex-col items-center border-gray-700 border-2">
<Image
alt=""
src={data.imageSrc}
height={600}
width={800}
className="col-span-2 mb-6"
/>
<div>
<h2 className="text-[1.5rem] mb-2 text-center">{data.title}</h2>
<p>{data.description}</p>
</div>
</div>
);
}
最初のカードコンポーネントをクリックすると、http://localhost:3000/works/1
に遷移し、以下のようなページが表示されることを確認できます。
モーダルを表示させる
いよいよモーダルを作っていきます。まずは、完成系の挙動をもう一度確認しましょう。何が起きているでしょうか?
- Worksページが表示されている上に覆い被さるようにして、モーダルウィンドウ(先ほど作ったCardDetailコンポーネント)が表示されている
- Cardコンポーネントをクリックした後にページ遷移が入っていない
ことが確認できると思います。これらを念頭に置いてください。
まずはモーダルウィンドウを作ります。worksディレクトリ以下に@modal/(..)works/[id]/page.tsx
を作成します。中身はapp/works/[id]/page.tsx
とほとんど変わらず、違いはModalコンポーネントで覆われているかどうかのみです。
import CardDetail from "../../../works/components/CardDetail/CardDetail";
import Modal from "../../../works/components/Modal/Modal";
import swagData, { Work } from "../../../data";
export default function PhotoModal({
params: { id },
}: {
params: { id: string };
}) {
const data: Work = swagData.find((p) => p.id === id)!;
return (
<Modal>
<CardDetail data={data} />
</Modal>
);
}
(Modalコンポーネントは、Nextgramからそのまま持ってきました。ここでは説明を省略します)
このファイルを作ると、app/works
からapp/works/1
に遷移する時に、app/works/[id]/page.tsx
に遷移するのではなくapp/works/@modal/(..)works/[id]/page.tsx
に置き換えることができます。
ではこのapp/works/@modal/(..)works/[id]/page.tsx
という謎のURLで何が起きているかを順に見ていきましょう。
@modal
はNext.js 13のParallel Routesという機能で、同じレイアウトで1つ以上のページを同時にレンダリングすることができるというものです。
その設定はapp/works/layout.tsx
で以下のように書くことで行うことができます。
export default function RootLayout(props: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html lang="en" className={PTSerif400.className}>
<body>
<header className="text-[4rem] h-60 bg-white flex relative">
<h1 className="left-[4rem] bottom-[2rem] absolute">Portfolio Page</h1>
</header>
{props.children}
{props.modal}
</body>
</html>
);
}
今まではchildren
しかレンダリングしていませんでしたが、props.children
とprops.modal
の2つをレンダリングしています。props.modal
は通常は空ですが、@modal
内のパス(今回の場合は./works/[id]/page.tsx
)が表示される時、元々のページ(props.children
)と@modal
の中身(props.modal
)が両方表示されることになるわけです。
layout.tsx
と@modal
は関係が深いので、同じ階層に配置する必要があります。
ただしこのままだと、props.modal
がない場合にレンダリングできず404エラーになってしまいます。それを防ぐために、@modal
ディレクトリ直下とlayout.tsxと同階層にdefault.tsxを配置します。
export default function Default() {
return null;
}
default.tsxがあると、props.modal
がない場合にエラーを出すのではなくnullを返すことができ、props.modal
がないときにもレンダリングできます。
続いて(..)works/[id]
の部分について。こちらはNext.js 13のIntercepting Routesという機能で、ディレクトリに(..)
のようなプレフィックスをつけると遷移するときに、本来の(..)
のない遷移先のページに遷移せず表示をインターセプト(横取り)することができるというものです。
今回はworks/[id]
に遷移しようとしたときにworks/[id]/page.tsx
に実際に遷移するのではなく(..)works/[id]/page.tsx
が画面を横取りすることになるわけです。
この(..)
というプレフィックスは、どの階層のパスをインターセプトするかによって(.)
や(..)(..)
などの表記になります。works
と(..)works
に着目すると、works
は@modal
を無視すると(..)works
の1個上の階層になります。なのでここでは、1個上の階層を示す(..)
を用いています。
このParallel RoutesとIntercepting Routesの2つを組み合わせ、works/[id]
に遷移しようとしたときに(..)works/[id]/page.tsx
を表示させる、かつ、works
と同時に表示させる ことで、遷移前のページに覆い被さるようなモーダルの挙動を実現しています。
最終的なファイル構成は以下の通りです。
コードはこちら
おわりに
ページパスまわりで詰まるとどこが間違っているのか非常にわかりづらく、大変時間がかかってしまいました。
(特にdefault.tsxがないと404エラーになる話、事前に聞いてはいたのにまんまとハマりました)
Next.js難しい…………!!
実は今回のようなポートフォリオサイトにNext.js 13が向いているかというとあまり向いていないかもしれません。モーダル部分は例外としてもほとんどが静的ページでサーバーコンポーネントしか使っていないので、Next.js 13の本領は発揮できておらずちょっともったいない。
同じサイトを作るのであれば、Astroなどの静的サイトに特化したフレームワークを使う選択肢というもあります。slotを使ったレイアウトの仕組みが似ていたり、mdファイルを使ってブログが簡単に書けたりします。
しかし複雑なサイトになればなるほどNext.jsが有利になってくるので、Next.js 13を始めてみる方にこの記事が少しでもお役に立てば幸いです。
追伸
所属OrganizationのTECH WOMAN KANSAIは、関西の女性エンジニアが集まるコミュニティです。
定期的にメンバーで集まって勉強会を開催したり、月に1回Qiitaに記事を投稿したりと活動しているので、ご興味ある関西在住の女性エンジニアの方はぜひ〜