記事一覧へ
6/24/2023

Next.jsのApp Routerでブログを作った

こんにちは、木瓜丸です。

今回、このサイトをリニューアルしてみました。

自分がやっていることを色々見えるようにすることが結構大事な気がしてきたので、 今度こそ頑張ってブログのカキコとメンテナンスを続けたいと思います。

さて、今回このサイトを作るにあたって、Next.jsのApp Routerを使ったので、簡単に使用技術についてまとめておきたいと思います。

このサイトの構成

今回はデータソースにはheadless CMS等は使わず、ファイルに直接markdownを書いてSSGする形にしました。

  • Next.js
    • remark
  • デプロイ先: Vercel

App Routerとは

App RouterはNext.js 13にて追加された新機能です。 12以前とは異なるディレクトリ構造、異なるPageコンポーネントの書き口で実装できるようになります。

従来のNext.jsでは、/pages配下に設置したコンポーネントに対して、ファイル名を元にルーティングされました。

├── public/
└── pages/
    └── index.tsx -> /からルーティングされるページ

対して、App Routerでは/app配下に設置されたlayout.tsxpage.tsxがページコンポーネントとなります。

├── public/
└── app/
    ├── layout.tsx -> このディレクトリ配下の共通のレイアウト
    └── page.tsx -> /からルーティングされるページ

最初見た時は気持ち悪かったですが、小さなコンポーネントが増えてくるとディレクトリの整理が圧倒的にしやすくなりました。 また、generateStaticParamsMetadataといった、他の13の新機能とのシナジーがかなり設計されていると感じました。

ディレクトリ構成

記事データは_posts/配下に配置しています。

後述しますが、src/libs/articles.ts_posts/配下から記事一覧、各記事データを生成する処理をまとめています。

.
├── _posts/
├── _works.json
├── next.config.js
├── node_modules
├── package.json
├── public/
├── src/
│   ├── app/
│   │   ├── _components/
│   │   ├── apple-icon.png
│   │   ├── blog/
│   │   │   ├── [slug]/
│   │   │   │   ├── page.module.scss
│   │   │   │   └── page.tsx
│   │   │   ├── layout.tsx
│   │   │   ├── page.module.scss
│   │   │   └── page.tsx
│   │   ├── colors.scss
│   │   ├── favicon.ico
│   │   ├── globals.scss
│   │   ├── layout.tsx
│   │   ├── opengraph-image.png
│   │   ├── page.module.scss
│   │   ├── page.tsx
│   │   └── twitter-image.png
│   └── libs
│       └── articles.ts
├── tsconfig.json
└── yarn.lock

記事データの処理

src/libs/articles.tsはこんなかんじになっております。

import * as fs from 'fs';
import * as path from 'path';
import * as glob from 'glob';
import matter from 'gray-matter';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeHighlight from 'rehype-highlight';
import rehypeKatex from 'rehype-katex';
import remarkMath from 'remark-math';
import gfm from 'remark-gfm';
import rehypeStringify from 'rehype-stringify';
import rehypeDocument from 'rehype-document';

export interface ArticleProps {
  slug: string;
  title: string;
  content: string;
  thumbnail?: string | null;
  html: string;
  tags: string;
  publishedAt: string;
}

export const getAllArticles = (): ArticleProps[] => {
  const fileNames = fs.readdirSync(path.join(process.cwd(), "./_posts/"));

  return fileNames.map((fileName) => {
    return getArticle(fileName);
  });
}

export const getArticle = (slug: string): ArticleProps => {
  const remarkHtml = unified()
    .use(remarkParse)
    .use(remarkRehype)
    .use(gfm)
    .use(rehypeHighlight)
    .use(remarkMath)
    .use(rehypeKatex)
    .use(rehypeDocument, {
      css: 'https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css'
    })
    .use(rehypeStringify);
  const file = fs.readFileSync(path.join(process.cwd(), "./_posts/", slug, "index.md"), 'utf8')
  const matterResult = matter(file)
  const thumbnailPaths = glob.globSync(path.join(process.cwd(), "./public/", slug, "thumbnail.*"));
  const thumbnail = thumbnailPaths.length > 0 ? thumbnailPaths[0] : null;
  const html = remarkHtml.processSync(matterResult.content).toString(); 
  return {
    slug,
    title: matterResult.data.title,
    tags: matterResult.data.tags,
    publishedAt: matterResult.data.publishedAt,
    content: matterResult.content,
    thumbnail,
    html
  };
}

記事一覧は次のような手順で生成します。

  1. _posts/ディレクトリ配下のディレクトリ一覧を取得する
  2. ディレクトリ一覧のディレクトリ名をslugとして、さらにその配下のindex.mdを読む

frontmatterはgray-matterパッケージを使って解析します。 また、markdown→htmlの変換にはremarkを使いました。

ページの生成処理

ブログ記事のページコンポーネント(src/app/blug/[slug]/page.tsx)は下記のようになっています。

import { getAllArticles, ArticleProps, getArticle } from '@/libs/articles';
import { Metadata } from 'next';

...

interface ArticlePageProps {
  params: ArticleProps
}

export default function Article({ params }: ArticlePageProps) {
  const article = getArticle(params.slug)
  return (
    ...
  )
}

export const generateStaticParams = () => {
  return getAllArticles()
}

export const generateMetadata = async ({ params }: {
  params: {
    slug: string
  }
}): Promise<Metadata> => {
  const { title, content, thumbnail } = getArticle(params.slug);
  return {
    title,
    description: content,
    openGraph: {
      title,
      description: content,
      images: [
        process.env.VERCEL_URL + (thumbnail ?? '/noimage.png')
      ]
    },
    twitter: {
      title,
      description: content,
      images: [
        process.env.VERCEL_URL + (thumbnail ?? '/noimage.png')
      ]
    }
  }
}

generateStaticParamsは、静的ファイルを生成する時のページ一覧を動的に生成するためのメソッドです。

このメソッドではDynamic Routesのプレースホルダの値一覧を返却します。 なので、ここではgetAllArticles()の結果を返却していますが、実際に使われるのはその中のslugプロパティだけです。 そのため、コンポーネント内で再度getArticle(parameter.slug)という形で記事の内容を再取得しています。

generateMetadataでは、OGPの内容を動的に設定できます。 これだけでもかなりありがたいですが、VercelにOGPのバリデーターが入っていて、結構大変なOGPの設定がメチャクチャやりやすくなりました。

VercelのOpen Graphタブ

思ったこと・やり残したこと

現在別のアプリもApp Routerで作っていますが、小規模なアプリや静的ページはすごく作りやすいなという印象です。

ただ、特に静的配信回りではまだ知見がたまっていないなという感じもしました。

今回ブログを書くにあたってmarkdownと画像を同じディレクトリで管理したりしてみたかったのですが、next-imagesnext-optimized-imagesは動作しませんでした。

今後進展があったらまた記事にしたいと思います。何か知っている方いらっしゃいましたら連絡下さいmm


書いた人

木瓜丸

Webエンジニア。2022年に「木瓜丸屋」を開業し、個人開発をしています。

その他プロフィールをチェック