🏛️

[Next.js]App Router時代の静的サイトの作り方

2023/05/30に公開

はじめに

Next.jsのApp Routerを用いて静的なサイトを作る際、pagesの時にやってたアレApp Routerだとどうやってやるんだっけということが多かったのでまとめてみます。

今回は、個人のブログを例に説明を行います。記事が更新されるのは管理者によるもののみなので、ビルド時にデータ取得を行い静的なhtmlを生成します。

https://github.com/hiromu617/blog

開発した成果物はこちらです。

https://blog-hiromu617.vercel.app/page/3

構成

App Routerの利点として、Server Component上で直接DBにアクセスできるため、API層を用意しなくて良いことが挙げられます。今回はその利点を活かして、API層を用意せず、Prismaを通してServeless DBであるPlanetscaleからデータを取得します。

スタイリングにはtailwindcssを使用します。tailwindcssはcssファイルを生成するだけなのでZero Runtimeであり、Server Componentと非常に相性が良いです。

一方で,CSS-in-JSは現在Server Componentではサポートしておらず、各ライブラリの対応待ちという状況のようです。

https://nextjs.org/docs/app/building-your-application/styling/css-in-js

また、tailwindcssベースのcssライブラリとしてdaisyUIを使用します。豊富なテーマが用意されており、classの記述量を減らすことができます。

daisyUIのドキュメントのthemesのスクリーンショット

静的エクスポートを有効にする

next.config.jsoutput: 'export'の記述を追加します。

これによって、ビルド時の生成物がNode.jsサーバーを必要としない静的なものになります。

ランタイムでのサーバーを必要としないのでS3やGithub pagesなどデプロイ先が幅広くなります。

next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
+  output: 'export',
}

module.exports = nextConfig

https://nextjs.org/docs/app/building-your-application/deploying/static-exports

この機能を有効にすることで、ridirectsやmiddlewareなどのサーバーを必要とする一部機能が使えなくなります。動的レンダリング(次項で説明する)も一切できなくなってしまうのでこの機能を有効にするかはアプリケーションの性質を考慮して検討した方が良いでしょう。

データの再検証(revalidate)もおこなうことができないので、記事を執筆したら再度ビルドをトリガーすることが必要になります。

https://nextjs.org/docs/app/building-your-application/deploying/static-exports#unsupported-features

記事一覧ページを作る

pages時代では、getStaticPropsgetServersidePropsを使ってSG、SSRの挙動を使い分けていましたが、App Routerではそれらを区別せずにServer Component内でデータの取得を行います。

静的レンダリングと動的レンダリングについて

App Routerでは静的レンダリングと動的レンダリングという新しいワードが登場しました。

静的レンダリングは、ビルド時にレンダリングされ、動的レンダリングは リクエスト時にレンダリングされるという挙動です。

(SSR、SGと変わらない気がしますが、Streaming SSRなどもあるので言葉を再定義した感じなのでしょうか。)

どちらの方法でレンダリングされるかは

  • fetchに指定したchacherevalidateの値、
  • headers()cookies()useSearchParamsなどのdynamic functionsを使用しているか

により決まります。

https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic-rendering

今回のように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と同じ挙動です。

記事一覧取得のコードは次にようになります。

src/app/page.tsx
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>
  );
}

詳細ページを作る

次に記事の詳細ページを実装します。コードは以下のようになります。

src/app/[slug]/page.tsx
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のネストがいらなくなり簡潔になりました。

https://nextjs.org/docs/app/api-reference/functions/generate-static-params

ここでは、全記事を取得してすべての記事の詳細ページを生成する、ということをしています。

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();
  }

https://nextjs.org/docs/app/api-reference/functions/not-found

表示する404ページは、not-found.tsx(jsx)という名前でおきます。

notFoundが呼ばれた箇所から一番近いnot-found.tsxが表示されるため、404ページを出し分けるということも可能です。

src/app/not-found.tsx
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のようにページングの数字をパスに含めることにより実現します。

コードは以下の通りです。

src/app/page/[page]/page.tsx
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もデフォルトで行ってくれるのでサクサクで高性能なサイトを作成することができます。

ここまで読んでくださりありがとうございました。

https://github.com/hiromu617/blog

https://blog-hiromu617.vercel.app/page/3

Discussion