[Next.js]App Router時代の静的サイトの作り方
はじめに
Next.jsのApp Routerを用いて静的なサイトを作る際、pagesの時にやってたアレApp Routerだとどうやってやるんだっけということが多かったのでまとめてみます。
今回は、個人のブログを例に説明を行います。記事が更新されるのは管理者によるもののみなので、ビルド時にデータ取得を行い静的なhtmlを生成します。
開発した成果物はこちらです。
構成
- ORM
- DB
- Hosting
- Styling
App Routerの利点として、Server Component上で直接DBにアクセスできるため、API層を用意しなくて良いことが挙げられます。今回はその利点を活かして、API層を用意せず、Prismaを通してServeless DBであるPlanetscaleからデータを取得します。
スタイリングにはtailwindcssを使用します。tailwindcssはcssファイルを生成するだけなのでZero Runtimeであり、Server Componentと非常に相性が良いです。
一方で,CSS-in-JSは現在Server Componentではサポートしておらず、各ライブラリの対応待ちという状況のようです。
また、tailwindcssベースのcssライブラリとしてdaisyUIを使用します。豊富なテーマが用意されており、classの記述量を減らすことができます。
静的エクスポートを有効にする
next.config.js
にoutput: 'export'
の記述を追加します。
これによって、ビルド時の生成物がNode.jsサーバーを必要としない静的なものになります。
ランタイムでのサーバーを必要としないのでS3やGithub pagesなどデプロイ先が幅広くなります。
/** @type {import('next').NextConfig} */
const nextConfig = {
+ output: 'export',
}
module.exports = nextConfig
この機能を有効にすることで、ridirectsやmiddlewareなどのサーバーを必要とする一部機能が使えなくなります。動的レンダリング(次項で説明する)も一切できなくなってしまうのでこの機能を有効にするかはアプリケーションの性質を考慮して検討した方が良いでしょう。
データの再検証(revalidate)もおこなうことができないので、記事を執筆したら再度ビルドをトリガーすることが必要になります。
記事一覧ページを作る
pages時代では、getStaticProps
、getServersideProps
を使ってSG、SSRの挙動を使い分けていましたが、App Routerではそれらを区別せずにServer Component内でデータの取得を行います。
静的レンダリングと動的レンダリングについて
App Routerでは静的レンダリングと動的レンダリングという新しいワードが登場しました。
静的レンダリングは、ビルド時にレンダリングされ、動的レンダリングは リクエスト時にレンダリングされるという挙動です。
(SSR、SGと変わらない気がしますが、Streaming SSRなどもあるので言葉を再定義した感じなのでしょうか。)
どちらの方法でレンダリングされるかは
- fetchに指定した
chache
やrevalidate
の値、 -
headers()
やcookies()
、useSearchParams
などのdynamic functionsを使用しているか
により決まります。
今回のようにfetchを使用しない場合はRoute Segment Configを使用することでレンダリングの方法を制御することができます。
export const dynamic = 'auto';
export const dynamicParams = true;
export const revalidate = false;
export const fetchCache = 'auto';
export const runtime = 'nodejs';
export const preferredRegion = 'all';
export default function MyComponent() {}
今回は前項で静的エクスポートを有効にしているので静的レンダリングが強制されます。ビルド時に一度だけデータを取得しHTMLを生成して、再度ビルドかけるまで永久に使用される、という挙動になります。つまり、pagesの時のgetStaticProps
と同じ挙動です。
記事一覧取得のコードは次にようになります。
import { prisma } from "@/libs/prismaClient";
import { ArticleCard } from "./_components/ArticleCard";
const getArticles = async () => {
const articles = await prisma.article.findMany({
take: 10,
orderBy: {
createdAt: "desc",
},
});
return articles;
};
export default async function Home() {
const articles = await getArticles();
return (
<div className="m-auto w-96 md:w-[768px] px-1">
<ul className="grid grid-cols-1 md:grid-cols-2 gap-5">
{articles.map((article) => (
<ArticleCard key={article.slug} article={article} />
))}
</ul>
</div>
);
}
詳細ページを作る
次に記事の詳細ページを実装します。コードは以下のようになります。
import { notFound } from "next/navigation";
import { getArticle } from "@/services/getArticle";
import { prisma } from "@/libs/prismaClient";
import { format } from "date-fns";
import { ArticleBody } from "../_components/ArticleBody";
export const generateStaticParams = async () => {
const articles = await prisma.article.findMany();
return articles.map((article) => ({
slug: article.slug,
}));
};
export default async function Detail({ params }: { params: { slug: string } }) {
const article = await getArticle(params.slug);
if (!article) {
notFound();
}
return (
<div className="w-full px-2 md:max-w-2xl mx-auto mt-10 mb-20">
<h1 className="text-3xl font-bold mb-5">{article.title}</h1>
<time className="block mb-10">
{format(new Date(article.createdAt), "yyyy.MM.dd")}
</time>
<ArticleBody body={article.body} />
</div>
);
}
まず目につくのはgenerateStaticParams
の関数でしょう。
この関数をは、ビルド時に実行され、静的に生成するルートを指定します。つまりpagesでのgetStaticPaths
と同様の関数です。戻り値にpaths
のネストがいらなくなり簡潔になりました。
ここでは、全記事を取得してすべての記事の詳細ページを生成する、ということをしています。
export const generateStaticParams = async () => {
const articles = await prisma.article.findMany();
return articles.map((article) => ({
slug: article.slug,
}));
};
実際にローカルでnpm run build
を実行してみると、ビルド時に各ページを生成していることがわかります。
直でurlを指定したときなど、記事が見つからない時は404ページを表示します。
こちらは、next/navigation
からimportしたnotFound
の関数を呼ぶことにより実装できます。
const article = await getArticle(params.slug);
if (!article) {
notFound();
}
表示する404ページは、not-found.tsx(jsx)
という名前でおきます。
notFound
が呼ばれた箇所から一番近いnot-found.tsx
が表示されるため、404ページを出し分けるということも可能です。
import Link from "next/link";
export default function NotFound() {
return (
<div>
<h2>Not Found</h2>
<p>Could not find requested resource</p>
<p>
View <Link href="/">all posts</Link>
</p>
</div>
);
}
ページネーションを実装する
次にページネーションを実装します。
こちらも静的レンダリングによりページを生成するので、/blogs/page/2
のようにページングの数字をパスに含めることにより実現します。
コードは以下の通りです。
import { Pagination } from "@/app/_components/Pagination";
import { getArticles } from "@/services/getArticles";
import { getArticlesCount } from "@/services/getArticleCount";
import { ArticleList } from "@/app/_components/ArticleList";
export const generateStaticParams = async () => {
const count = await getArticlesCount();
const range = (start: number, end: number) =>
[...Array(end - start + 1)].map((_, i) => start + i);
const paths = range(2, Math.ceil(count / 10)).map((num) => ({
page: `${num}`, //stringにしなければいけない
}));
return paths;
};
export default async function Index({ params }: { params: { page: number } }) {
const articlesData = getArticles(params.page);
const countData = getArticlesCount();
const [articles, count] = await Promise.all([articlesData, countData]);
return (
<div className="m-auto w-96 md:w-[768px] px-1">
<ArticleList articles={articles} />
<div className="pt-8 pb-12 text-center">
<Pagination totalCount={count} />
</div>
</div>
);
}
詳細ページと同様に、generateStaticParams
によってビルド時にページを生成しています。
ページネーションのコンポーネントは次の通りです。
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
type Props = {
totalCount: number;
};
const range = (start: number, end: number) =>
[...Array(end - start + 1)].map((_, i) => start + i);
const PER_PAGE = 10;
export const Pagination: React.FC<Props> = ({ totalCount }) => {
const params = useParams();
const currentPage = params?.page ? Number(params.page) : 1;
return (
<div className="btn-group">
{range(1, Math.ceil(totalCount / PER_PAGE)).map((number, index) => (
<Link
href={number === 1 ? "/" : `/page/${number}`}
key={index}
className={`btn ${currentPage !== number ? "btn-active" : ""}`}
aria-current={currentPage === number ? "page" : undefined}
>
{number}
</Link>
))}
</div>
);
};
現在のページを取得するためにuseParams
というhookを使っています。こちらを使用すると動的パラメータを取得することができます。pagesでのuseRouter
と同じですね。
hooksを使用しているのでこのコンポーネントはClient Componentとなります。
"use client";
おわりに
App Routerは、レンダリングの使い分けなど難しい部分が多いですが、API層を用意しなくて良いのは大きな利点かなと思いました。
また、データのprefetchもデフォルトで行ってくれるのでサクサクで高性能なサイトを作成することができます。
ここまで読んでくださりありがとうございました。
Discussion