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? … 任意の名前
Would you like to use TypeScript with this project? … No / Yes
自分は「Yes」を選択しました。
Would you like to use ESLint with this project? … No / Yes
自分は「Yes」を選択しました。
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」を選択しました。
Use App Router (recommended)? … No / Yes
自分は「Yes」を選択しました。
Would you like to customize the default import alias? … No / Yes
自分は「No」を選択しました。
全ての解答後以下のコマンドを入力後「ウェルカムページが表示されます」
$ npm run dev
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
を使用してデータを取得できます。また、データ取得時にはfetch
APIを使用します。(今回の作ったブログサイトでは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.tsx
はClient componentとして扱われるためuse client
をつけましょう。
先ほど、コンポーネントは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.js13からnext/image
の仕様が変更されました。
詳しくは下記URLをご参照いただきたいのですが今回の話で言うとlayout
とobjectFit
が廃止されました。
さらに、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(),
}));
}
Next.js側で用意されているもので、getStaticPaths
の代わりに使うもの。(App Routerでは使えないので)
※この関数がなくても問題はないがSSGにはならない。
Metadata APIというもが存在し、そこにメタ情報を書いていきます。(layout.tsxのexport const metadata = {}
)
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);
現時点では、4ページ目は存在しません。手動でブラウザの URL に/page/4にアクセスを行います。ですが、表示されるページにはブログの情報が含まれていないだけでエラーは表示されません。
ページが存在しない4ページ目にアクセスがあった場合には 404 ページを表示させるためにnext/navigation
のNotFound関数
を利用します。
// 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ページに飛んでくれるようになります。
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/