サムネイル

Next.js13のApp Routerについて勉強した

Next.js13.4からApp RouterがStableになったので改めて勉強してみようと思ったので記事にしてみました。
この記事ではブログサイトを作りながら、Next.js13の機能について紹介していきたいと思います。
https://watataku-blog-app-router.vercel.app/ ← 今回のデモ

プロジェクトの作成

まずは下記コマンドを入力し、アプリの雛形を作成します。

$ npx create-next-app@latest

上記コマンド入力後、いくつか質問されるので答えていきましょう。

  • このプロジェクトの名前はなんですか?
What is your project named? … 任意の名前
  • このプロジェクトで TypeScript を使用しますか?
Would you like to use TypeScript with this project? … No / Yes

自分は「Yes」を選択しました。

  • このプロジェクトで ESLint を使用しますか?
Would you like to use ESLint with this project? … No / Yes

自分は「Yes」を選択しました。

  • このプロジェクトで Tailwind CSS を使用しますか?
Would you like to use Tailwind CSS with this project? … No / Yes

自分は「Yes」を選択しました。

  • このプロジェクトで src/ を使用しますか?
Would you like to use `src/` directory with this project? … No / Yes

自分は「No」を選択しました。

  • App Router を使用しますか (推奨)?
Use App Router (recommended)? … No / Yes

自分は「Yes」を選択しました。

  • デフォルトのインポートエイリアスをカスタマイズしますか?
Would you like to customize the default import alias? … No / Yes

自分は「No」を選択しました。

全ての解答後以下のコマンドを入力後「ウェルカムページが表示されます」

$ npm run dev

App Routerについて

Next.js13の1番の目玉の新機能ではないでしょうか?ここではApp Router(app/)について解説します。

  • ルーティング

今回肝となるファイルがpage.tsxです。雛形を作った段階ではapp/の直下にpage.tsxがあります。これによりルートにアクセスすると「ウェルカムページ」が表示されるわけです。
では、/aboutのページを作るにはどうすればいいのでしょうか?
app/about/page.tsx を作成するだけです。

// app/about/page.tsx

const About = () => {
  return <h1>Aboutページ</h1>;
};

export default About;
  • データフェッチ

getStaticPropsや getServerSideProps は App Router では使えません。ではどうすればいいのかというと、代わりに Server Component(後述) で async/awaitを使用してデータを取得できます。また、データ取得時にはfetchAPIを使用します。(今回の作ったブログサイトではmicrocms-js-sdkを使用しています。)

fetch('https://...'); or fetch('https://...', { cache: 'force-cache' }); // キャッシュを利用するしてデータを取得する


fetch('https://...', { cache: 'no-store' }); // キャッシュを利用せずに常に新しいデータを取得する


fetch('https://...', { next: { revalidate: 10 } }); // キャッシュされたデータを一定の間隔で再検証する。リソースの有効期間 (秒)
  • コンポーネント

コンポーネントはapp/に作っていきます。ただし、デフォルトではServer Componentになります。
Client Componentとして扱いたい場合、ファイルの最初の1行に"use client" と記述します。

ブログサイトのトップページの作成

以上を踏まえ、トップページを作成していきます。コンテンツ管理には「microCMS」を使っていきますので「microCMS」を使う準備をしていきましょう。

$ npm install --save microcms-js-sdk

※バージョンが2.5.0(2022/06/15時点)以上であることを確認すること。確認する理由は2.5.0以上でないとApp Routerに対応していないから(表現が正しいかどうかはわかりませんw)。詳しくは下記リンクから
https://blog.microcms.io/microcms-js-sdk-250/

※今回の記事データ新規でデータを作成するのがめんどくさかったので、既存のブログデータを使用しています。

// libs/client.js
import { createClient } from "microcms-js-sdk";

export const client = createClient({
  serviceDomain: process.env.NEXT_PUBLIC_SERVICE_DOMAIN as string,
  apiKey: process.env.NEXT_PUBLIC_API_KEY as string,
});

ブログ一覧を作成する

// app/page.tsx
import { client } from "../libs/client";
import type { Blog, BlogContents } from "../types/blog";
import Card from "./Card";

const getAllPosts = async (): Promise<Blog[]> => {
  const data: BlogContents = await client
    .get({
      customRequestInit: {
        next: {
          revalidate: 10,
        },
      },
      endpoint: "blog",
    })
    .catch((e) => {});

  // エラーハンドリングを行うことが推奨されている
  if (!data) {
    throw new Error("Failed to fetch articles");
  }

  return data.contents;
};
export default async function Home() {
  const blogs = await getAllPosts();
  return (
    <ul className="w-[1100px] tbpc:w-[95%] maxsp:w-[100%] min-h-[calc(100vh_-_170px)] m-auto flex flex-wrap justify-between maxsp:justify-center">
      {blogs.map((blog: Blog) => {
        return (
          <Card
            id={blog.id}
            thumbnail={blog.thumbnail.url}
            title={blog.title}
            tags={blog.tags}
            publishedAt={blog.publishedAt}
            key={blog.id}
          />
        );
      })}
    </ul>
  );
}

getAllPosts()でデータフェッチしています。

エラーハンドリング

サーバーコンポーネント内で例外がthrowされた場合app/error.tsxの内容が表示されます。

// app/error.tsx
"use client"; // Error components must be Client components
import { useEffect } from "react";

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  useEffect(() => {
    console.error(error);
  }, [error]);

  return (
    <main className="relative flex justify-center items-center min-h-[calc(100vh_-_170px)]">
      <h2 className="text-4xl dark:text-white">予期せぬエラーが発生しました</h2>
      <div className="absolute bottom-3">
        <button onClick={() => reset()}>Try again</button>
      </div>
    </main>
  );
}

Errorコンポーネントは以下のpropsを受け取ります

  • error : throwされた例外オブジェクト
  • reset: 例外が発生したコンポーネントを再レンダリングするための関数

また、app/error.tsxClient componentとして扱われるためuse clientをつけましょう。

Cardコンポーネントを作成する

先ほど、コンポーネントはapp/に作成すると説明しました。なので、app/Card.tsxを作成します。

// app/Card.tsx
import Link from "next/link";
import Image from "next/legacy/image";
import type { Tags } from "../../types/blog";

type Props = {
  id: string;
  thumbnail: string;
  title: string;
  tags: Tags[];
  publishedAt: string;
};

const Card = (props: Props) => {
  return (
    <li className="relative mb-2.5 mt-3.5 cursor-pointer  border w-[350px] h-[380px] tbpc:w-[30vw] tbpc:h-[310px] maxsp:w-[95%] border-black dark:border-white  hover:translate-x-0 hover:translate-y-1.5 bg-white dark:bg-black ">
      <Link href={`/blog/${props.id}`} passHref>
        <Image
          className="object-cover aspect-video w-full h-auto"
          src={props.thumbnail}
          unoptimized={true}
          width={350}
          height={200}
          alt={"サムネイル"}
        />

        <h2 className="pt-3 pr-3 pl-3 text-lg font-medium overflow-hidden webkit-line-clamp dark:text-white">
          {props.title}
        </h2>

        <ul className="flex flex-start flex-wrap mt-1">
          {props.tags.map((tag: Tags) => {
            return (
              <li
                key={tag.id}
                className="flex justify-center items-center p-0.5 border-solid border-2 ml-2 mb-2 border-[#5bbee5] dark:border-[#7388c0] dark:text-white"
              >
                {tag.tag_name}
              </li>
            );
          })}
        </ul>

        <time
          datatype={props.publishedAt}
          className="absolute bottom-[5px] right-[5px] dark:text-white"
        >
          {props.publishedAt}
        </time>
      </Link>
    </li>
  );
};
export default Card;

next/imageについて

今回の内容から少しずれちゃいますがNext.js13からnext/imageの仕様が変更されました。
詳しくは下記URLをご参照いただきたいのですが今回の話で言うとlayoutobjectFitが廃止されました。
さらに、imgのラッパーがなくなりました。
具体的に、Next.js12以前でnext/imageを仕様すれば下記のようになっていました。

// Next.js 12未満
<div ... >
  <img ... />
</div>

// Next.js 12
<span ... >
  <img ... />
</span>

これが、Next.js13ではラッピングされなくなりました

<img ... / >

https://nextjs.org/docs/pages/api-reference/components/image

話を戻しますが、サンプルコードではnext/legacy/imageを使ってNext.js12以前のものを使っています。これを使うと、以前までのnext/imageを使うことができます。
ちなみにNext.js13のnext/imageを使ってサンプルの画像表示と同じように実装すると下記のように実装します。(抜粋)

import Image from "next/image";

<Image
  className="object-cover aspect-video w-full h-auto"
  src={props.thumbnail}
  unoptimized={true}
  width={350}
  height={200}
  alt={"サムネイル"}
/>

ブログサイトの個別ページを作る

ブログサイトの個別ページを作るためにはダイナミックルーティングとやらを使わないといけません。以前までならgetStaticPaths
getServerSidePathsを使っていましたが、当然のことそんなものは使えません。
では、どうすればいいのか紹介していきます。

ファイルを作成する

以前まではpages/[slug].tsxみたいな感じで、ダイナミックルーティング用のファイルを作成していました。
今回のApp Routerではapp/[slug]/page.tsxとします。

export default function Page({ params }: { params: { slug: string } }) {
  return <h1>{params.slug}</h1>;
}

このように書くとparamsにパラメータが渡ってきます。

これを踏まえて今回のブログページを作っていきます。

// app/blog/[slug]/page.ts
import { client } from "../../../libs/client";
import type { Blog } from "../../../types/blog";

const getPost = async (slug: string): Promise<Blog> => {
  const blog: Blog = await client.get({
    customRequestInit: {
      next: {
        revalidate: 10,
      },
    },
    endpoint: "blog",
    contentId: slug,
  });
  // エラーハンドリングを行うことが推奨されている
  if (!blog) {
    throw new Error("Failed to fetch articles");
  }
  return blog;
};

export default async function Page({ params }: { params: { slug: string } }) {
  const blog = await getPost(params.slug);
  return <div dangerouslySetInnerHTML={{ __html: blog.body }} />;
}

export async function generateStaticParams() {
  const Limit = 999;
  const blogs: BlogContents = await getMicroCMSBlogs(Limit);

  return blogs.contents.map((blog: Blog) => ({
    slug: blog.id.toString(),
  }));
}

generateStaticParamsとは

Next.js側で用意されているもので、getStaticPathsの代わりに使うもの。(App Routerでは使えないので)
※この関数がなくても問題はないがSSGにはならない。

メタデータを仕込む

Metadata APIというもが存在し、そこにメタ情報を書いていきます。(layout.tsxのexport const metadata = {})

layout.tsxとは

app/layout.tsxはRoot Layoutと呼ばれ、すべてのページに適用されるレイアウトを定義します。
Next.jsでは、自動的に<html><body>タグを生成しないため、必ずapp/layout.tsxでこれらを定義する必要があります。

少し脱線しましたが、メタデータの設定方法を紹介していきます。

export const metadata = {
  title: {
    default: "サイトのタイトル",
    template: `%s | サイトのタイトル`, 
  },
  description: "サイトのディスクリプション",
  openGraph: {
    title: "og:title",
    description: "og:description",
    url: "og:url",
    siteName: "og:site_name",
    type: "og:type",
    images: "og:image",
  },
  twitter: {
    card: "twitter:card",
    title: "twitter:title",
    description: "twitter:description",
    images: "twitter:image:src",
  },
};

動的にメタデータを仕込む

genarateMetadata()と言うものが用意されています。
これを使用することで動的にメタデータというものを仕込むことができます。
今回のブログサイトの個別ページにメタデータを仕込みます。

// app/blog/[slug]/page.tsx
import { MetaData } from "next";
...

const getPost = async (slug: string): Promise<Blog> => {
  // 省略
};

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const blog = await getPost(params.slug);
  return {
    title: blog.title,
    description: blog.body,
    openGraph: {
      title: blog.title,
      description: blog.body,
      url: "http://localhost:3000/blog/" + blog.id,
      siteName: blog.title,
      type: "article",
      images: blog.thumbnail.url,
    },
    twitter: {
      card: "summary_large_image",
      title: blog.title,
      description: blog.body,
      images: blog.thumbnail.url,
    },
  };
}

export default async function Page({ params }: { params: { slug: string } }) {
  // 省略
}

これで個別のブログサイトごとに個別のメタ情報、個別のOGP画像を仕込むことができました。

ページング機能を実装する

最後にページング機能を実装します。
/blog/page/2みたいな感じで静的レンダリングしたいのでを生成したいので下記のようなコードになります。

// app/blog/page/[pageNo]/page.tsx
import { client } from "@/libs/client";
import Card from "@/app/Card";
import Pagination from "@/app/Pagination";
import { range } from "@/functions/function";

const PER_PAGE = 9; // 1ページに表示するコンテンツ数

const getAllPosts = async (pageNo: number): Promise<BlogContents> => {
  const data = await client.get({
    customRequestInit: {
      next: {
        revalidate: 10,
      },
    },
    endpoint: "blog",
    queries: {
      limit:  PER_PAGE,
      offset: (pageNo - 1) * PER_PAGE
    },
  });
  // エラーハンドリングを行うことが推奨されている
  if (!data) {
    throw new Error("Failed to fetch articles");
  }
  return data;
};
export default async function Home({ params }: { params: { pageNo: string } }) {
  const pageNo = Number(params.pageNo);
  const blogs = await getAllPosts(pageNo);
  return (
    <>
      <ul className="w-[1100px] tbpc:w-[95%] maxsp:w-[100%] min-h-[calc(100vh_-_170px)] m-auto flex flex-wrap justify-between maxsp:justify-center">
      {blogs.map((blog: Blog) => {
          return (
            <Card
              id={blog.id}
              thumbnail={blog.thumbnail.url}
              title={blog.title}
              tags={blog.tags}
              publishedAt={blog.publishedAt}
              key={blog.id}
            />
          );
        })}
      </ul>
      {blogs.totalCount > PER_PAGE && (
        <Pagination totalCount={blogs.totalCount} />
      )}
    </>
  );
}

export async function generateStaticParams() {
  const blogs = awat client.get({
    customRequestInit: {
      next: {
        revalidate: 10,
      },
    },
    endpoint: "blog",
  });
  return range(1, Math.ceil(blogs.totalCount / PER_PAGE)).map((repo) => ({
    pageNo: repo.toString(),
  }));
}

次にページングのコンポーネントを見ていきます。

import Link from "next/link";
import { range } from "../functions/function";

type Props = {
  totalCount: number;
};
const Pagination = (props: Props) => {
  const PER_PAGE = 9;

  return (
    <div className="flex justify-center mt-4">
      {range(1, Math.ceil(props.totalCount / PER_PAGE)).map((number, index) => (
        <p key={index} className="text-center list-none">
            <Link
              href={`/page/${number}`}
              className="mx-0.5 w-[30px] h-[40px] flex justify-center items-center text-2xl p-[2.5%] rounded-md text-black bg-[#5bbee5] hover:bg-blue-800 hover:text-white dark:text-white dark:bg-[#7388c0] dark:hover:text-black dark:hover:bg-white"
            >
              {number}
            </Link>
        </p>
      ))}
    </div>
  );
};

export default Pagination;

ページング機能を実装するにあたってpage.tsx,Pagination.tsxの両方に出てくるrange()について紹介します。

このrange()は自身が作った関数になっていてページング機能を実装するにあたっての「キモ」となる関数です。

// functions/function.ts
export const range = (start: number, end: number) =>
  [...Array(end - start + 1)].map((_, i) => start + i);

Not Foundの設定

現時点では、4ページ目は存在しません。手動でブラウザの URL に/page/4にアクセスを行います。ですが、表示されるページにはブログの情報が含まれていないだけでエラーは表示されません。
ページが存在しない4ページ目にアクセスがあった場合には 404 ページを表示させるためにnext/navigationNotFound関数を利用します。

// app/blog/page/[pageNo]/page.tsx
...
import { notFound } from "next/navigation"; // 追加

const PER_PAGE = 9;

const getAllPosts = async (pageNo: number): Promise<BlogContents> => {
  // 省略
};
export default async function Home({ params }: { params: { pageNo: string } }) {
  const pageNo = Number(params.pageNo);
  const blogs = await getAllPosts(pageNo);
  // ----------------------追加-------------------------
  if (blogs.contents.length == 0) {
    notFound();
  }
  // ----------------------追加-------------------------
  return (
    <>
      {/* 省略 */}
    </>
  );
}

export async function generateStaticParams() {
 // 省略
}

以上を追記することでページが存在しないページにアクセスがあったときは404ページに飛んでくれるようになります。

オリジナルのNot Foundページを作成する

Pages Routerを使用していた時には、pages/404.tsxに作成していましたが、App Routerではapp/not-found.tsxに作成します。

// app/not-found.tsx
import Link from "next/link";

export default function NotFound() {
  return (
    <section className="text-center min-h-[calc(100vh_-_170px)]">
      <h2 className="text-3xl font-bold mb-6 dark:text-white">
        <span className="text-red-700 tracking-[5px] text-9xl maxsp:text-8xl mb-4">
          404
        </span>
        <br />
        お探しのページは見つかりませんでした。
      </h2>
      <p className="text-xl mb-8 maxsp:text-base dark:text-white">
        あなたがアクセスしようとしたページは削除されたかURLが変更されているため、
        見つけることができません。
        <br />
        以下の理由が考えられます。
      </p>
      <ul className="p-5 mb-6 m-auto text-left border-4 border-gray-400 dark:border-gray-50 w-[55%] tbpc:w-[65%] maxsp:w-[85%] dark:text-white">
        <li className="list-disc ml-5 maxsp:text-xs">
          記事がまだ公開されていない。
        </li>
        <li className="list-disc ml-5 maxsp:text-xs">
          アクセスしようとしたファイルが存在しない。(ファイルの設置箇所を誤っている。)
        </li>
        <li className="list-disc ml-5 maxsp:text-xs">URLが間違っている。</li>
      </ul>
      <div className="w-[90%] text-right">
        <Link
          className=" text-blue-800 border-b-blue-800 dark:border-[#ff36ab] dark:text-[#ff36ab] hover:border-b ml-auto"
          href={"/"}
        >
          TOPへ戻る
        </Link>
      </div>
    </section>
  );
}

最後に今回の記事で作成したApp Routerを使用してのブログサイトのコードも下記リンクにおいておくのでよければご覧下さい。
https://github.com/watataku8911/watataku-blog-app-router

まだまだ自分もNext.js13についてキャッチアップ中でわかってないところだらけですがこれからどんどんとキャッチアップしていきたいです。それでは良い Next.js ライフを!

参考

https://nextjs.org/docs
https://blog.microcms.io/microcms-next-jamstack-blog/