Next.js 13でポートフォリオサイトを作ってみる

# はじめに
フロントエンドエンジニア2年目の @Mk459 と申します。Next.jsがバージョン13で新しくなりたくさんの機能が増えたものの全くキャッチアップできていなかったので、主要機能である

* App router
* Parallel Routes
* Intercepting Routes

を触りつつ、ポートフォリオサイトのテンプレートのようなものを作ってみました。ポートフォリオサイトの中に「Works」ページがあり、その中に作品一覧が並んでいて、その作品をクリックすると作品の詳細を見ることができます。

![output-palette.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279026/645125ef-adc5-a582-f901-06f73d428940.gif)

本記事の内容は、[公式ドキュメント](https://nextjs.org/docs/app/building-your-application)と公式のサンプルプロジェクト[Nextgram](https://github.com/vercel-labs/nextgram/tree/main)を踏襲しています。Next.js 13は現状ドキュメントも簡素でチュートリアルもないので、ドキュメントやコードを読むだけではあまり理解できない自分のような初心者でも手順を追って作っていけるように記事を構成しました。（特にモーダル周りの実装について重点的に触れています）

# 手順
## プロジェクトを作る

まずは`create-next-app`コマンドで新しいプロジェクトを作成します。
```shell
npx create-next-app@latest
```
そのあといくつか質問されますが、今回は以下のように回答しました。App Routerを使うか？の項目ではYesを選ぶのを忘れないようにします。
```shell
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
```
すると以下のようなフォルダ構成のディレクトリが作成されます。

<img width="200" alt="スクリーンショット 2023-07-14 2.19.09.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279026/29375008-b7a5-8c29-6762-c70147d01038.png">

以下のコマンドで実行すると…
```
npm run dev
```

最初のページが表示できました！

![スクリーンショット 2023-07-14 2.20.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279026/fbd87d6e-4865-0e24-0959-64c6aa923505.png)

## ページを作る
### 新しくページを作る
仕組みはとてもシンプルで、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`は一番上位なルートレイアウトとなり、全てのページ（ルート）に適用されるものになります。

```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は以下のように書かれているので、
```tsx:app/page.tsx
export default function Home() {
  return (
    <main>
      <div>
        <p>
          Get started by editing&nbsp;
        </p>
　　　　　　　　　　　　　　　　・・・
```
`{children}`に挿入されると実際にはこのような構造になります。
```html
<html lang="en">
  <body>
   <main>
      <div>
        <p>Get started by editing&nbsp;</p>
        ...
  </body>
</html>
```

layout.tsxはappディレクトリ直下だけではなくpage.tsxと同じ階層に配置することができ、配置するとそのパス以下のファイル全てにそのレイアウトが適用されます。

従来はコンポーネントを組み合わせてレイアウトを組んでいくことが多かったですが、Next.js13ではこのようにlayout.tsxというレイアウト専用のファイルが用意され、複数ページに渡る共通レイアウトが実装しやすくなりました。

### スタイリングする
レイアウトの仕組みがわかったところで、`app/layout.tsx`と`app/works/layout.tsx`のスタイルを調整していきます。

```tsx:app/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>
  );
}
```

```tsx:app/works/layout.tsx
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>
  );
}
```

![スクリーンショット 2023-07-16 11.54.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279026/30bb02fa-2b71-9a87-82bc-4d021d070ed3.png)


## モーダルを作る
### カードコンポーネントを作る
作品一覧ページの外枠ができたので、中身を作っていきます。各作品の画像・作品名を表示する部分をCardコンポーネントと呼ぶことにします。

![a.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279026/8d9893ce-8b86-0280-4e6c-57c4aae598e9.png)

まずは`app/data.ts`に、必要なデータを定義します。もちろんDBと接続してAPIで取得しても良いのですが、ここでは省略してローカルにデータを置いておくことにします。

```ts:app/data.ts
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`として作成し、
```tsx: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のデータを渡します。
```tsx:app/works/page.tsx
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です！

![スクリーンショット 2023-07-17 18.26.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279026/d1c099bc-419d-5e1c-4b3d-6bcda2e1ec28.png)

### 通常の遷移先のページを作る
今の時点では、カードコンポーネントのLinkタグに設定したURL(`/works/${id}`)が存在しないので、カードコンポーネントをクリックしても404ページに飛んでしまいます。

そこで、クリックしたら開く各作品ごとのページを、Next.js 12以前にもあったDynamic Routesを使って作っていきます。

今回は、各作品ごとに割り当てられているidをkeyにして、`works/1`、`works/2`のようなページを動的に作りたいとします。この場合、worksの下に`[id]`というフォルダを新しく作り、その中のページコンポーネントで引数として渡すparamsパラメーターに、idを設定します。
```tsx:app/works/[id]/page.tsx
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コンポーネントとして作っておいて、モーダルではなくページに埋め込むことにしました。

```tsx:app/works/components/CardDetail/CardDetail.tsx
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`に遷移し、以下のようなページが表示されることを確認できます。

![CardDetail.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279026/26d9f01f-493a-daa5-60a6-892ca59687eb.png)


### モーダルを表示させる
いよいよモーダルを作っていきます。まずは、完成系の挙動をもう一度確認しましょう。何が起きているでしょうか？

![output-palette.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279026/645125ef-adc5-a582-f901-06f73d428940.gif)

* Worksページが表示されている上に覆い被さるようにして、モーダルウィンドウ（先ほど作ったCardDetailコンポーネント）が表示されている
* Cardコンポーネントをクリックした後にページ遷移が入っていない 

ことが確認できると思います。これらを念頭に置いてください。

---

まずはモーダルウィンドウを作ります。worksディレクトリ以下に`@modal/(..)works/[id]/page.tsx`を作成します。中身は`app/works/[id]/page.tsx`とほとんど変わらず、違いはModalコンポーネントで覆われているかどうかのみです。
```tsx:app/@modal/(..)works/[id]/page.tsx
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からそのまま持ってきました](https://github.com/vercel-labs/nextgram/blob/main/src/components/modal/Modal.tsx)。ここでは説明を省略します）

**このファイルを作ると、`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`で以下のように書くことで行うことができます。
```tsx: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`は関係が深いので、同じ階層に配置する必要があります。

<img width="300" alt="スクリーンショット 2023-07-18 21.20.41.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279026/039feb07-805a-e1ec-153c-66fa4c518d53.png">

ただしこのままだと、`props.modal`がない場合にレンダリングできず404エラーになってしまいます。それを防ぐために、`@modal`ディレクトリ直下とlayout.tsxと同階層にdefault.tsxを配置します。
```tsx:app/works/@modal/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`が画面を横取りすることになるわけです。

<img width="300" alt="スクリーンショット 2023-07-18 21.26.07.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279026/ff2027c9-c48e-2738-df53-060b3041d361.png">

この`(..)`というプレフィックスは、どの階層のパスをインターセプトするかによって`(.)`や`(..)(..)`などの表記になります。`works`と`(..)works`に着目すると、`works`は`@modal`を無視すると`(..)works`の1個上の階層になります。なのでここでは、1個上の階層を示す`(..)`を用いています。

---

このParallel RoutesとIntercepting Routesの2つを組み合わせ、**`works/[id]`に遷移しようとしたときに`(..)works/[id]/page.tsx`を表示させる、かつ、`works`と同時に表示させる** ことで、遷移前のページに覆い被さるようなモーダルの挙動を実現しています。

![output-palette.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279026/645125ef-adc5-a582-f901-06f73d428940.gif)

最終的なファイル構成は以下の通りです。

<img width="200" alt="スクリーンショット 2023-07-18 1.51.33.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279026/999616e5-fd38-b11d-3707-5fa3163fd8a2.png">

コードは[こちら](https://github.com/Maku459/portfolio-template)

# おわりに
ページパスまわりで詰まるとどこが間違っているのか非常にわかりづらく、大変時間がかかってしまいました。
（特にdefault.tsxがないと404エラーになる話、事前に聞いてはいたのにまんまとハマりました）
Next.js難しい…………！！

実は今回のようなポートフォリオサイトにNext.js 13が向いているかというとあまり向いていないかもしれません。モーダル部分は例外としてもほとんどが静的ページでサーバーコンポーネントしか使っていないので、Next.js 13の本領は発揮できておらずちょっともったいない。

同じサイトを作るのであれば、[Astro](https://astro.build/)などの静的サイトに特化したフレームワークを使う選択肢というもあります。[slotを使ったレイアウトの仕組み](https://docs.astro.build/en/core-concepts/layouts/)が似ていたり、[mdファイルを使ってブログが簡単に書け](https://docs.astro.build/en/core-concepts/astro-pages/#markdownmdx-pages)たりします。

しかし複雑なサイトになればなるほどNext.jsが有利になってくるので、Next.js 13を始めてみる方にこの記事が少しでもお役に立てば幸いです。

### 追伸

所属OrganizationのTECH WOMAN KANSAIは、関西の女性エンジニアが集まるコミュニティです。

定期的にメンバーで集まって勉強会を開催したり、月に1回Qiitaに記事を投稿したりと活動しているので、ご興味ある関西在住の女性エンジニアの方はぜひ〜

https://qiita.com/organizations/tech-woman-kansai

https://tech-woman-kansai.connpass.com/