18
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

お題は不問!Qiita Engineer Festa 2023で記事投稿!

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

Last updated at Posted at 2023-07-18

はじめに

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

  • App router
  • Parallel Routes
  • Intercepting Routes

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

output-palette.gif

本記事の内容は、公式ドキュメントと公式のサンプルプロジェクト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

すると以下のようなフォルダ構成のディレクトリが作成されます。

スクリーンショット 2023-07-14 2.19.09.png

以下のコマンドで実行すると…

npm run dev

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

スクリーンショット 2023-07-14 2.20.44.png

ページを作る

新しくページを作る

仕組みはとてもシンプルで、appディレクトリ以下にディレクトリ名/page.tsxを作成すると、ディレクトリ名がそのままページになります。

最初はappディレクトリ直下にapp/page.tsxがあり、これが一番上のページ(localhost:3000)になります。

app/works/page.tsxを作ると、localhost:3000/worksでアクセスできるようになります。

レイアウトの仕組みを知る

初期ページは、app/page.tsxapp/layout.tsxの2つのファイルで構成されています。

layout.tsxは、その名の通りレイアウトを作るためのファイルです。テンプレートのようなものです。app/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.tsxapp/layout.tsxが適用されるので、page.tsx(でreturnされるJSX)がchildrenというpropsを通して挿入されます。

page.tsxは以下のように書かれているので、

app/page.tsx
export default function Home() {
  return (
    <main>
      <div>
        <p>
          Get started by editing&nbsp;
        </p>
                ・・・

{children}に挿入されると実際にはこのような構造になります。

<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.tsxapp/works/layout.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>
  );
}
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

モーダルを作る

カードコンポーネントを作る

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

a.png

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

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として作成し、

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のデータを渡します。

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

通常の遷移先のページを作る

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

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

今回は、各作品ごとに割り当てられているidをkeyにして、works/1works/2のようなページを動的に作りたいとします。この場合、worksの下に[id]というフォルダを新しく作り、その中のページコンポーネントで引数として渡すparamsパラメーターに、idを設定します。

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コンポーネントとして作っておいて、モーダルではなくページに埋め込むことにしました。

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

モーダルを表示させる

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

output-palette.gif

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

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


まずはモーダルウィンドウを作ります。worksディレクトリ以下に@modal/(..)works/[id]/page.tsxを作成します。中身はapp/works/[id]/page.tsxとほとんど変わらず、違いはModalコンポーネントで覆われているかどうかのみです。

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からそのまま持ってきました。ここでは説明を省略します)

このファイルを作ると、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で以下のように書くことで行うことができます。

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.childrenprops.modalの2つをレンダリングしています。props.modalは通常は空ですが、@modal内のパス(今回の場合は./works/[id]/page.tsx)が表示される時、元々のページ(props.children)と@modalの中身(props.modal)が両方表示されることになるわけです。

layout.tsx@modalは関係が深いので、同じ階層に配置する必要があります。

スクリーンショット 2023-07-18 21.20.41.png

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

スクリーンショット 2023-07-18 21.26.07.png

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


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

output-palette.gif

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

スクリーンショット 2023-07-18 1.51.33.png

コードはこちら

おわりに

ページパスまわりで詰まるとどこが間違っているのか非常にわかりづらく、大変時間がかかってしまいました。
(特に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に記事を投稿したりと活動しているので、ご興味ある関西在住の女性エンジニアの方はぜひ〜

18
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?