homehome

ブログをNext.js App Routerで再構築

Published

TocMenu目次

Next.js v13のApp Routerが安定してきたようなので、Page Routerで作成されていた当ブログをApp Routerで書き換えてみました。この記事は後日メモです。

記事内のコードは必要部分を抜粋しているため、関係ない部分は前後で整合性が取れていない場合があるかもしれません。尚、投稿記事はContentfulというヘッドレスCMSを使っており、Next.jsのバージョンは執筆時最新の13.5.3を使用しています。加えて全コードをTypeScriptで書き直しています。

前後しますが、Pages Routerからの移行に悩んでいる人は、App Router Incremental Adoption Guideを読むのがおすすめです。

プロジェクトの進め方

App RouterとPages Routerは並行して使用できるため、徐々にApp Routerに書き換えていくことができます。また、デプロイ先がVercelならば移行もスムーズにできます。

例えば、コードはクローンしてプロジェクトをまっさらな状態で始めます。リモートリポジトリ(GitHub)は新規作成し、Vercelも新規プロジェクトとしてデプロイします。コードの改修が終わりプレビュービルドに問題がないことが確認できたら、Vercel上の既存プロジェクト(Pages Router ver)の管理git先を新規リポジトリ(App Router ver)に差し替えます。

project-flow

Page Routerで動作しているリポジトリはノータッチで新しい環境に切り替えることができます。後戻りしたくなったら管理git先を元のリポジトリに切り替えればいいだけです。ブランチでも似たことはできるため好みの問題ではありますが、使い捨て感覚で作業できます。

prettier

ESLintはNext.jsに結合されていますが、Prettierはインストールされていないため、以下の手順に沿う必要があります。尚、エディタはVSCodeを使用しています。

まず、競合の回避用に、eslint-config-prettierをインストールします。

yarn add --dev prettier
yarn add --dev eslint-config-prettier

そして.eslintrc.jsonにprettierを追加します。

{
  "extends": [
    "next/core-web-vitals",
    "prettier"
  ]
}

ルールは.prettierrc、無視したいファイルは.prettierignoreにそれぞれ記述します。自分はシングルクォートよりダブルクォート派なので、singleQuoteはfalseにしています。

{
  "semi": true,
  "tabWidth": 2,
  "printWidth": 80,
  "singleQuote": false,
  "trailingComma": "es5",
  "useTabs": false,
}

また、VSCode用にPrettier、ESLint、Format Code Actionの3つの拡張をインストールします。

次にsettings.jsonに以下を記述します。

{
  // ESLintが検証する言語
  "eslint.probe": [
    "javascript",
    "javascriptreact",
    "typescript",
    "typescriptreact",
    "html"
  ],
  // 保存時のエディタの書式設定を無効化
  "editor.formatOnSave": false,
  // VSCodeのデフォルトフォーマッター設定
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  // フォーマットをコードアクションとして行う
  "editor.codeActionsOnSave": ["source.formatDocument", "source.fixAll.eslint"],
   // prettierの設定ファイルがある場合のみpretteirを有効にする
  "prettier.requireConfig": true,
}

これで保存時に自動フォーマットが機能します。vimを使用していても動作するので便利です。

自動importソート

さらにimport文を指定した正規表現の手順で自動ソートさせることができます。まず、prettier-plugin-sort-importをインストールします。

yarn add --dev @trivago/prettier-plugin-sort-imports

.prettierrcの末尾に以下を追加します。ただし、インポートのルールは各々の環境に合わせる必要があります。このブログでは、@にエイリアスを設定しているため、サードモジュール、エイリアス、相対パスの順番で優先順位を定義しています。

{
  //...
  "importOrder": ["<THIRD_PARTY_MODULES>", "^@/(.*)$", "^[./]"],
  "importOrderSeparation": true,
  "importOrderSortSpecifiers": true,
  "plugins": ["@trivago/prettier-plugin-sort-imports"]
}

これでファイル保存時にインポート文が自動ソートされます。

ディレクトリ構造

Pages Routerはpagesディレクトリ配下に作成したファイルがページを作成する構成でしたが、App Routerはappディレクトリ配下のpage.tsxが実際のページ作成に使用されます。Pages Routerのときと比べ、特殊な意味を持つファイルやディレクトリ名も増えています。後述するルートグループなどを使うと、必ずしもディレクトリの階層=ページのURLというわけではなくなります。

一部割合していますが、以下は当ブログのディレクトリ構造です。ホーム、記事、アーカイブ、カテゴリページの4種のページしかありませんが、ファイル数はPages Routerに比べ増えてしまっています。

/
┣ app/
┃  ┣ (list)/
┃  ┃  ┣ archive/
┃  ┃  ┃  ┗ page.tsx
┃  ┃  ┣ category/
┃  ┃  ┃  ┗ page.tsx
┃  ┃  ┣ laout.tsx
┃  ┃  ┗ not-found.tsx
┃  ┣ api/
┃  ┃  ┗ og/
┃  ┃    ┗ route.ts
┃  ┣ feed.xml/
┃  ┃  ┗ route.ts
┃  ┣ post/
┃  ┃  ┗ [slug]/
┃  ┃    ┗ page.tsx
┃  ┣ not-found.tsx
┃  ┣ page.tsx
┃  ┣ layout.tss
┃  ┗ sitemap.ts
┣ components/
┣ hooks/
┣ public/
┣ styles/
┣ types/
┗ util/

appディレクトリ内に記述されたコンポーネントはサーバコンポーネントとなります。このため、サーバレンダリングさせるには、すべてをapp内に配置しなければいけないと考えるかもしれませんが、appの外にあるコンポーネントをインポートしても、そのコンポーネントがクライアントコンポーネントと明記されていなければ、それはサーバコンポーネントとして扱われます。

"use client";

// このコンポーネントはクライアントコンポーネント
export default function FC() {...}

上記でappの中にあるのは基本的にpage.tsxlayout.tsxといった特殊ファイル名で、コンポーネントやスタイルなどは存在していません。これはサイト内のルートをシンプルに表すためです。当初、app内にすべてをまとめていたのですが、煩雑すぎるため上記に変更しました。

もしapp内にすべてをまとめたい場合は、ルートは後述するルートグループを使い、(routes)というディレクトリに格納するといいかもしれません。

ルートグループ

App Routerでは、(folderName)のようにディレクトリを括弧で囲むとルートグループとしてマークできます。そのディレクトリはルートのURLパスには含まれません。このブログでは、アーカイブとカテゴリーページが同じレイアウトを取るため、(list)というルートグループを作成して共通レイアウトを作成しています。

スタイル

Pages Routerのときは、Theme UIというCSSフレームワークを使用していましたが、フレームワークの基盤になるEmotionにサーバレンダリング時のバグが残っていたため、App RouterではCSSモジュールでスタイルを設定しました。尚、前述のバグは現在は取り除かれているようです。

CSSモジュールのためCSSファイルが多々作成されるわけですが、管理を容易にするため、<使用されるルート名>.<page | layout>.module.cssという命名規則を設けました。例えば、ホームのpage.tsxで使用するものならば、home.page.module.css、投稿記事のlayout.tsxで使用するならば、post.layout.module.cssという具合です。

# /post/page.tsx
post.page.module.css
# /post/layout.tsx
post.layout.module.css
# /page.tsx
home.page.module.css
# /layout.tsx
root.layout.module.css

レンダリング

Next.jsは静的レンダリング、動的レンダリングがあります。これらはPages Routerでも存在する概念です。Next.jsはデフォルトでは静的レンダリングされます。これはブログのようなあらかじめ提供するコンテンツが決まっているサイトには適したレンダリング方法です。静的レンダリングされたページは、CDNからユーザへ提供されます。

また、App Routerではfetch()の結果をキャッシュします(cacheを指定しない場合、force-catchが適用されます)。キャッシュを参照させない場合はno-storeを指定し、revalidateを指定すると、指定時間キャッシュされます。

// 結果をキャッシュ
// Pages RouterのgetStaticPropsと同等
fetch(`https://...`, { cache: 'force-cache' })

// リクエストごとに再取得
// Pages RouterのgetServerSidePropsと同等
fetch(`https://...`, { cache: 'no-store' });

// 有効時間10秒でキャッシュ
// Pages RouterのgetStaticPropsにreavalidateを指定したものと同等
fetch(`https://...`, { next: { revalidate: 10 } });

Headless CMSを使用して静的なページを作成している場合、従来は個別ルートのフェッチを担当するgetStaticPropsと生成する動的ルートのパスを示すgetStaticPathsを使用していました。App Routerではfetch()を拡張したフェッチリクエストとgenerateStaticParamsを用いて記事を生成します。

// app/post/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPosts, getSinglePostForPost } from "@/utils/request";

export async function generateStaticParams() {
  // 全投稿を取得
  const posts = await getPosts();
  const params = posts.map(({ fields: { slug } }) => ({
    slug,
  }));
  return params;
}

export default async function Page({ params }: Props) {
  const { slug } = params;

  // 1件分の投稿を取得
  const post = await getSinglePostForPost(slug);
  if (!post) return notFound();

  //...
}

上記ではgetPosts()getSinglePostForPost()という関数が割合されていますが、通常はこの部分で前述したfetch()を使いCMSからリソースを取得します。ただし、Contentfulはクライアントライブラリが用意されているため、fetch()を使用する必要がありません(RESTful APIのためfetch()も可能ですが)。サードパーティライブラリを使用しつつ、キャッシュと再検証の動作を変更したい場合は、ルートセグメント構成オプションで設定ができるようです。

not-found

next/navigationにはnotFoundという便利なユーティリティ関数があります。これは呼び出すと、その時点でNEXT_NOT_FOUNDというエラーがスローされます。つまり、後続の処理を飛ばしエラー画面を出力できます。上記のように記事のフェッチに問題がある場合に役立ちます。

デフォルトで組み込みエラーページは用意されていますが、rootにnot-found.tsxを配置しておくと、そのエラーページをカスタムすることができます。

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

export default function NotFound() {
  return (
    <div>
      <h2>Not Found</h2>
      <p>リソースが見つかりません</p>
      <Link href="/">Homeに戻る</Link>
    </div>
  );
}

ただし、前述のルートグループを作成している場合は、そのルートグループはrootルートから除外されているため、ルートグループ内に同様のファイルを配置する必要がありました。

mdx

Pages Routerでは記事本文に当たるマークダウンソースはmarkedを使いレンダリングしていました。サーバレンダリングを行う場合は、next-mdx-remoteが現状のベストプラクティスです。

import rehypePrism from "@mapbox/rehype-prism";
import { MDXRemote } from "next-mdx-remote/rsc";
import { notFound } from "next/navigation";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeSlug from "rehype-slug";
import { remark } from "remark";
import remarkGfm from "remark-gfm";
import remarkUnwrapImages from "remark-unwrap-images";
import TableOfContents from "@/components/TableOfContents";
import headingTree from "@/utils/ast/getTocHeading";
import { getPosts, getSinglePostForPost } from "@/utils/request";

export default async function Page({ params }: Props) {
  const { slug } = params;
  const post = await getSinglePostForPost(slug);
  if (!post) return notFound();

  const { title, description, body } = post.fields;

  // TOCを生成するために整形
  const processedContent = await remark().use(headingTree).process(body!);
  const source = processedContent.value.toString();

  // HTML要素ごとのレイアウト(後述)
  const mdxComponents = {}

  return (
  	<>
      <TableOfContents nodes={processedContent.data.headings} />
      <MDXRemote
        source={source}
        components={mdxComponents}
        options={{
          mdxOptions: {
            remarkPlugins: [
              // GitHub Flavore
              remarkGfm,
              // imgを囲むpを削除
              remarkUnwrapImages,
            ],
            // ヘッダーにid属性を付与、HTMLの見出しにリンクを追加
            rehypePlugins: [
              rehypePrism, 
              rehypeSlug, 
              rehypeAutolinkHeadings
            ],
          },
        }}
      />
  	</>
  );
}

source

かなり割合していますが、sourceにマークダウンソースを渡すだけです。

options

出力されるコードを補正するために以下のremarkとrehypeのプラグインを使用しています。

TypeScriptを使用しているとrehype-prismでタイプエラーが発生するため、@types/mapbox__rehype-prismのインストールが必要です。

TOCはremark-tocを使えば自動生成可能ですが、カスタマイズ性が乏しいため自前で生成しています。これを解説するとそれだけで記事1つ分になってしまうため割合しますが、前述のrehypeプラグイン2つとmdast-util-to-string, unist-util-visit, github-sluggerで実現しています。単にIDを振ればいいのではなく、rehype-slugで見出しに振られるIDと等しくする必要があります。

components

componentsには、カスタマイズしたいHTML要素ごとのコンポーネントを記述します。Next.jsを使用している場合は、img要素を指定することが多いのではないでしょうか。

const mdxComponents = {
	img: (props: NewImageProps) => {
    const newAlt: string =
      typeof props.alt === "string" ? props.alt : "no alt provided";
    const newSrc: string =
      typeof props.src === "string" ? props.src : "nosrc";

    // contentfulの画像セットからサイズを取得
    let width, height;
    imageAssets.forEach((file) => {
      if (file.url === props.src) {
        width = file.details.image!.width;
        height = file.details.image!.height;
      }
    });

    return (
      <Image
        src={`https:${newSrc}`}
        alt={newAlt}
        width={width}
        height={height}
        className={styles.wrapImg}
      />
    );
  },
};

ここで使用しているImageコンポーネントはnext/imageですが、これをcomponentsに指定すると、TypeScriptでエラーが出ます。Issuesにも報告されており、以下のようなTypeを作成して解決しています。

export type NewImageProps = Omit<
  ImageProps,
  "src" | "alt" | "width" | "height" | "placeholder"
> & {
  src?: ImageProps["src"] | undefined;
  alt?: ImageProps["alt"] | undefined;
  width?: ImageProps["width"] | string | undefined;
  height?: ImageProps["height"] | string | undefined;
  placeholder?: ImageProps["placeholder"] | string | undefined;
};

メタデータ

App Routerでは静的メタデータと動的メタデータ用のAPIが用意されています。両者とも決まったフォーマットのプロパティを持つオブジェクトをexportする点は同じで、静的か動的かの違いだけです。

メタデータはpagelayoutのどちらに記述してもよく、ページやレイアウトがネストされると同名のプロパティはオーバーライドされます。注意したいのは、オブジェクトは浅いマージになることです。OpenGraphのようなオーバーライドされやすいプロパティは、別ファイルに分けて使用時にインポートするのが好ましいです。

レイアウトファイルに定義した静的メタデータの例

// app/layout.tsx

import { shareTwitter } from "@/components/SharedMetadata";
import { Metadata } from "next";
import config from "@/blog.config";

export const metadata: Metadata = {
  creator: config.creator,
  formatDetection: {
    email: false,
    address: false,
    telephone: false,
  },
  // 絶対パスが必要なメタデータのベースURL
  // 相対パスで指定できるようになる
  metadataBase: new URL(currentDomain),
  // 子ルートセグメントでtitleが指定されていない場合はdefault、
  // 指定されていれば%sの部分にtitleが埋め込まれる
  title: {
    template: `%s | ${config.title}`,
    default: config.title,
  },
  twitter: {
    card: "summary_large_image",
    creator: `@${config.twitterId}`,
    site: `@${config.twitterId}`,
  },
};

個別記事ページの動的メタデータの例

// app/post/[slug]/page.tsx

import { Metadata } from "next";
import { getSinglePostForPost } from "@/utils/request";
import { shareOG } from "@/components/SharedMetadata";

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await getSinglePostForPost(params.slug);

  return {
    title,
    description,
    alternates: {
      canonical: `post/${params.slug}`,
    },
    openGraph: {
      type: "article",
      title,
      description,
      url: `post/${params.slug}`,
      images: [
        {
          url: `/api/og?title=${encodeURIComponent(title)}`,
          width: 1200,
          height: 630,
          alt: "",
        },
      ],
      // 各ページ間で共通のメタデータ(浅いマージ対策)
      ...shareOG,
    },
  };
}

全オプションはこちらに記載されています。

Open Graph Image

VercelにOpen Graphに必要な画像を生成する方法が用意されました。メタデータのimagesでも指定していますが、リクエストを投げると自動で画像を生成してくれます。実装方法はが用意されているため割合しますが、Route Hanlder(Pages RouterのAPI Routesと同等)内からImageResopnseインスタンスを返すだけです。

open graph image

デフォルトで使用できるフォントはNoto Sans fontのみです。フォントを追加することは可能ですが、最大バンドルサイズは500KBです。実行時フェッチにすることでOG Image Generationの制約は超えられますが、hobbyプランで日本語フォントを追加してみるとプロジェクトのデプロイ時にVercelから制限オーバーが表示されました(ローカルでは問題なく表示される)。

これはHobbyプランのEdge Runtimeに別の上限があるからだと思われます。

JSONLD

JSON-LDもメタデータの一種ですが、別の定義方法になります。スキーマに沿ったオブジェクトをscriptタグで書き出せばいだけなのですが、ページごとに最適化するにはやや手間がかかります。記述はpage.tsxもしくはlayout.tsxのどちらでもOKです。結果はコンポーネントにネストされて出力されるため、head内への配置は無理のようです。

TypeScriptのサポートもされているので、schema-dtsというライブラリを開発依存関係にインストールすると補完と厳密な検証が可能になります。

コードは汎用的なコンポーネントとして作成することになると思います。以下は、このブログの投稿ページを示すスキーマ例です。

// components/Schema/ArticleSchema/index.tsx

import { BlogPosting, WebPage, WithContext } from "schema-dts";
import config from "@/blog.config";

/**
 * 記事の投稿を表すスキーマ
 * @param headline 見出し
 * @description 記事の説明
 * @datePublished 記事の投稿日
 * @dateModified 記事の更新日
 * @slug 記事のスラッグ
 * @categoryName カテゴリ名(キーワードで使用)
 */
export default function ArticleSchema({
  headline,
  description,
  datePublished,
  dateModified,
  slug,
  categoryName,
}: {
  headline: string;
  description: string;
  datePublished: string;
  dateModified: string;
  slug: string;
  categoryName: string;
}) {
  const { domain, creator, twitterId } = config;

  const jsonLd: WithContext<BlogPosting> = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline,
    datePublished,
    dateModified,
    author: {
      "@type": "Person",
      name: creator,
      url: `https://twitter.com/${twitterId}`,
    },
    image: `${domain}/api/og?title=${encodeURIComponent(headline)}`,
    keywords: [categoryName, "Web開発"],
    description: description,
    mainEntityOfPage: {
      "@type": "WebPage",
      "@id": `${domain}/post/${slug}`,
    } as WithContext<WebPage>,
  };

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
    />
  );
}

その他、パンくずリストやアーカイブページを示すスキーマも別途定義しました。

RSSフィード

公式にRSSフィードの提供方法は明記されていませんが、これはAPI Handlerを使って実現できます。app内のディレクトリにroute.tsファイルを作成すると、それがAPIエンドポイントのページであってもルーティングされます。

要はAPI Hander内でRSSを書き出せばOKということです。RSSのフォーマットの作成には任意のライブラリを使用できます。このブログは以前からfeedを使用していたので、これを継続して使用しています。

以下は、このブログのRSSフィード例です。

// app/feed.xml/route.ts
import { Feed } from "feed";
import { remark } from "remark";
import remarkHtml from "remark-html";

import config from "@/blog.config";
import client from "@/utils/contentful";

export async function GET() {
  const { title, description, domain, creator, email, twitterId } = config;
  const date = new Date();
  const author = {
    name: creator,
    email: email,
    link: `https://twitter.com/${twitterId}`,
  };

  // 初期化
  const feed = new Feed({
    title: title,
    description: description,
    id: domain,
    link: domain,
    language: "ja",
    image: `${domain}/api/og?title=the2g`,
    favicon: `${domain}/favicon.ico`,
    copyright: `All rights reserved ${date.getFullYear()}, ${creator}`,
    updated: date,
    generator: "Next.js using Feed for Node.js",
    feedLinks: {
      rss: `${domain}/feed.xml`,
    },
    author,
  });

  // 個別フェッチ
  const posts = await client
    .getEntries({
      content_type: "blogPost",
      order: ["-fields.publishedDate"],
    })
    .then((response) => response.items);

  for (const post of posts) {
    const url = `${domain}/post/${post.fields.slug}`;
    const data = (
      await remark().use(remarkHtml).process(`${post.fields.body}`)
    ).value.toString();

    feed.addItem({
      title: `${post.fields.title}`,
      id: url,
      link: url,
      description: `${post.fields.description}`,
      content: data,
      author: [author],
      contributor: [author],
      date: new Date(`${post.fields.publishedDate}`),
    });
  }

  return new Response(feed.rss2(), {
    headers: {
      "Content-Type": "application/xml",
    },
  });
}

サイトマップ

従来、サイトマップは外部ライブラリを使用する方法がよく取られていました。App Routerではライブラリ不要でより簡単な方法が提供されています。appディレクトリ内にsitemap.tsというファイルを作成し、関数内から各ルートごとに以下のようなオブジェクトを返すだけです。

{
  url: URL,
  lastModified: Date,
  changeFrequency: 変更頻度,
  priority: 優先度
}

changeFrequencypriorityはオプションで、最近実装されました。以下は当ブログの例です。

import { MetadataRoute } from "next";

import config from "@/blog.config";
import { getPosts } from "@/utils/request";
import { getCategory } from "@/utils/request";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const today = new Date();
  const { domain } = config;

  // blog posts
  const posts = await getPosts();
  const blogs = posts.map((post) => ({
    url: `${domain}/post/${post.fields.slug}`,
    lastModified: post.sys.updatedAt,
  }));

  // category
  const categories = await getCategory();
  const category = categories.map((category) => ({
    url: `${domain}/category/${category.fields.slug}`,
    lastModified: category.sys.updatedAt,
  }));

  // home, archive, rss
  const staticPage = [
    {
      url: domain,
      lastModified: today,
    },
    {
      url: `${domain}/archive`,
      lastModified: today,
    },
    {
      url: `${domain}/feed.xml`,
      lastModified: today,
    },
  ];

  return [...staticPage, ...blogs, ...category];
}

これで/sitemap.xmlにアクセスすると、サイトマップが表示されます。

まとめ

可能な範囲で要約し、機能面に特化した記事になっているかと思います。

App Routerへの移行は簡単でしたが、この記事で割合した自前のTOC作成が非常に手間がかかりました。UnifiedやMdastとかあの辺は極めようとすると沼ですね。