Next.js14で静的なMarkdownブログを作成するサンプル

Next.js14で静的なMarkdownブログを作成するサンプル

Next.js 14 で App Router を使用して静的なビルドができる Markdown ブログを作成したメモ。

プロジェクトの作成

プロジェクトを作成したいディレクトリで下記コマンドを入力してプロジェクトを作成します。

npx create-next-app@latest

下記のように聞かれるので、プロジェクト名を入れたら基本的には初期値のまま Enter を押して進めていきます(TypeScript=Yes、TailwindCSS=Yes、AppRouter-Yes)

Ok to proceed? (y) y
√ What is your project named? ... [プロジェクト名]
√ Would you like to use TypeScript? ... No / Yes
√ Would you like to use ESLint? ... No / Yes
√ Would you like to use Tailwind CSS? ... No / Yes
√ Would you like to use `src/` directory? ... No / Yes
√ Would you like to use App Router? (recommended) ... No / Yes
? Would you like to customize the default import alias (@/*)? » No / Yes

作成したら

cd [プロジェクト名]
npm run dev

と実行し、動作確認を行います。

必要なパッケージのインストール

後述する今回の Markdown ブログ作成に必要なパッケージのインストールを行っておきます。

npm install gray-matter install marked react-mark rehype rehype-external-links rehype-parse rehype-prism-plus rehype-raw rehype-slug rehype-stringify remark remark-html remark-parse remark-prism

ディレクトリ構成

ディレクトリ構成は下記のようにしています。

既存コードから起こしたものなので別で使用している bootstrap のスタイルなどが適用されていますが、とりあえず無視しても構築は問題ないかと思います。

├─app
│  │  layout.tsx
│  │  page.tsx          -- トップページ
│  │
│  ├─lib
│  │      constants.ts
│  │      functions.ts
│  │      types.ts
│  │
│  ├─page               -- ページ表示用
│  │  └─[page]
│  │          page.tsx
│  │
│  ├─posts              -- 投稿記事表示
│  │  └─[slug]
│  │          page.tsx
│  │
│  └─tags               -- タグ一覧ページ
│      └─[slug]
│          │  page.tsx
│          │
│          └─[page]     -- タグ一覧ページ表示用
│                  page.tsx
├─components            -- 各種表示用コンポーネント
│      Pagination.tsx
│      PostCard.tsx
├─posts                 -- マークダウンファイル格納用
|      markdown-post.md -- 記事ファイル

posts ディレクトリ内の取得

まず posts ディレクトリ内の Markdown ファイルを読み込むための下準備を行います。

app/lib/constants.ts

// 1ページに表示する記事数
const POSTS_PER_PAGE = 15;

export { POSTS_PER_PAGE };

app/lib/functions.ts

import fs from "fs";
import path from "path";
import { POSTS_PER_PAGE } from "./constants";
import { PageData, PostItem } from "./types";
import matter from "gray-matter";

// すべてのposts内データを取得
const getPostData = async (): Promise<PostItem[]> => {
  const postsDirectory = path.join(process.cwd(), "posts");
  const filenames = fs.readdirSync(postsDirectory);
  const posts = filenames
    .map((filename) => {
      const filePath = path.join(postsDirectory, filename);
      const fileContents = fs.readFileSync(filePath, "utf-8");
      const { data } = matter(fileContents);

      return {
        slug: filename.replace(/\.md$/, ""),
        title: data.title,
        description: data.description,
        date: data.date,
        image: data.image,
        tags: data.tags || [],
        contentHtml: "",
      };
    })
    .sort((postA, postB) =>
      new Date(postA.date) > new Date(postB.date) ? -1 : 1
    );

  return posts;
};

// タグ一覧ページ用記事データ
async function getTagsData(slug: string): Promise<PostItem[]> {
  const posts = await getPostData();

  return posts
    .filter((post) => post.tags?.includes(decodeURIComponent(slug)))
    .sort((postA, postB) =>
      new Date(postA.date) > new Date(postB.date) ? -1 : 1
    );
}

const createPageData = (
  currentPage: number,
  totalPostCount: number
): PageData => {
  const page = currentPage;
  const totalPages = Math.ceil(totalPostCount / POSTS_PER_PAGE);
  const start = (page - 1) * POSTS_PER_PAGE;
  const end = start + POSTS_PER_PAGE;
  const pages = Array.from({ length: totalPages }, (_, i: number) => {
    return 1 + i;
  });

  return {
    currentPage: currentPage,
    totalPages: totalPages,
    start: start,
    end: end,
    pages: pages,
  };
};

export { getPostData, getTagsData, createPageData };
export type { PageData };

app/lib/types.ts

// 記事データ
export type PostItem = {
  slug: string;
  title: string;
  description?: string;
  date: string;
  image: string | null;
  tags: string[] | null;
  contentHtml: string;
};

// ページング情報
export type PageData = {
  currentPage: number;
  totalPages: number;
  start: number;
  end: number;
  pages: number[];
};

posts ディレクトリに Markdown 記事を追加

posts ディレクトリにマークダウンファイル下記のように追加しておきます。

追加で public/images/ディレクトリに sample.png ファイルを適当な画像で良いので追加しておきます。(なければ消しておいて大丈夫です)

posts/markdown-post.md

---
date: "2022-02-02"
description: "サンプル記事ですよ"
image: "/images/sample.png"
tags: ["Sample"]
title: "サンプル記事"
---

# サンプル記事

サンプルテキスト

表示用コンポーネントの作成

画面表示用に使い回すコンポーネントを定義しておきます。

components/PostCard.tsx

import Link from "next/link";
import { PostItem } from "../app/lib/types";

const PostCard = ({ post }: { post: PostItem }) => {
  return (
    <Link
      href={`/posts/${post.slug}`}
      className="align-self-baseline col-lg-4 d-flex flex-column justify-content-between scale-95 hover:scale-100 ease-in duration-100"
    >
      {post.image && (
        <div className="border rounded-lg mx-auto">
          <picture>
            <img
              src={`${post.image}`}
              width={600}
              height={300}
              alt={post.title}
              className="object-contain img-fluid img-thumbnail"
              style={{ maxWidth: "100%", height: "224px" }}
            />
          </picture>
        </div>
      )}
      <div className="px-2 py-3 mt-auto">
        <h1 className="font-bold text-lg">{post.title}</h1>
        <span className="badge bg-secondary text-white">{post.date}</span>
      </div>
    </Link>
  );
};

export default PostCard;

ページング表示用のコンポーネントを生成

components/Pagination.tsx

import Link from "next/link";

interface PageProps {
  type: string;
  pages: number[];
  currentPage?: number;
}

const Pagination = ({ type, pages, currentPage = 1 }: PageProps) => {
  const totalPages = pages.length;
  const pageLimit = 5;

  // 計算した開始ページと終了ページを決定
  let startPage = Math.max(currentPage - Math.floor(pageLimit / 2), 1);
  let endPage = Math.min(startPage + pageLimit - 1, totalPages);

  // ページ数が足りない場合は調整
  if (endPage - startPage + 1 < pageLimit) {
    startPage = Math.max(endPage - pageLimit + 1, 1);
  }

  const paginationRange: number[] = [];
  for (let i = startPage; i <= endPage; i++) {
    paginationRange.push(i);
  }

  return (
    <ul className="pagination justify-content-center">
      {startPage > 1 && (
        <>
          <li className="page-item">
            <Link href={`/${type}/1`} className="page-link">
              1
            </Link>
          </li>
          {startPage > 2 && (
            <li className="page-item disabled">
              <span className="page-link">...</span>
            </li>
          )}
        </>
      )}
      {paginationRange.map((page) => (
        <li className="page-item" key={page}>
          <Link
            href={`/${type}/${page}`}
            className={`page-link ${currentPage == page ? "active" : ""}`}
          >
            {page}
          </Link>
        </li>
      ))}
      {endPage < totalPages && (
        <>
          {endPage < totalPages - 1 && (
            <li className="page-item disabled">
              <span className="page-link">...</span>
            </li>
          )}
          <li className="page-item">
            <Link href={`/${type}/${totalPages}`} className="page-link">
              {totalPages}
            </Link>
          </li>
        </>
      )}
    </ul>
  );
};

export default Pagination;

記事一覧ページの作成

トップページに先ほどまでに作成した posts ディレクトリ内の markdown ファイル一覧を取得する関数と、コンポーネントを使用して記事一覧を表示していきます。

app/page.tsx

import PostCard from "../components/PostCard";
import Pagination from "../components/Pagination";
import { PageData, createPageData, getPostData } from "./lib/functions";

export default async function Home() {
  const posts = await getPostData();

  const pageData: PageData = createPageData(1, posts.length);

  return (
    <div className="container">
      <div className="row">
        {posts.slice(pageData.start, pageData.end).map((post) => (
          <PostCard key={post.slug} post={post} />
        ))}
      </div>
      <div className="mb-3">
        <Pagination
          type="page"
          pages={pageData.pages}
          currentPage={pageData.currentPage}
        />
      </div>
    </div>
  );
}

記事一覧をページングする

1 画面に記事が表示されまくるのもまずいので、最初に定義した「app/lib/constants.ts」ファイルの「POSTS_PER_PAGE」の定数の数まで記事が表示されたら改ページされるようにします。

「app」ディレクトリに「page」ディレクトリを作成し、その中に「[page]」というディレクトリを作成します。

「app/page/[page]」に「page.tsx」を作成し、下記のようにします。

app/page/[page]/page.tsx

import PostCard from "../../../components/PostCard";
import Pagination from "../../../components/Pagination";
import { Metadata, ResolvingMetadata } from "next";
import { POSTS_PER_PAGE } from "../../lib/constants";
import { PageData, createPageData, getPostData } from "../../lib/functions";

type Props = {
  params: { page: number };
};

export async function generateMetadata(
  { params }: Props,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const title = `${params.page}ページ目`;
  return {
    title: `${title} | ブログタイトル`,
    description: `${title}`,
  };
}

// 静的ルートの作成
export async function generateStaticParams() {
  const posts = await getPostData();

  const totalPages = Math.ceil(posts.length / POSTS_PER_PAGE);

  const pages = Array.from({ length: totalPages }, (_, i) => {
    return {
      path: `/page/${i + 1}`,
      page: `${i + 1}`,
    };
  });

  return pages;
}

export default async function Page({ params }: { params: { page: number } }) {
  const posts = await getPostData();

  const pageData: PageData = createPageData(params.page, posts.length);

  return (
    <div className="container">
      <div className="row">
        {posts.slice(pageData.start, pageData.end).map((post) => (
          <PostCard key={post.slug} post={post} />
        ))}
      </div>
      <div className="mb-3">
        <Pagination
          type="page"
          pages={pageData.pages}
          currentPage={pageData.currentPage}
        />
      </div>
    </div>
  );
}

これでページングが可能になりました。

記事表示ページの作成

app ディレクトリに「posts」というディレクトリを生成し、その中に「[slug]」という名前でディレクトリを生成します。

その中に「page.tsx」ファイルを作成して下記のようにします。

app/posts/[slug]/page.tsx

import fs from "fs";
import path from "path";
import Link from "next/link";
import React from "react";
import matter from "gray-matter";
import { remark } from "remark";
import html from "remark-html";
import { rehype } from "rehype";
import rehypeRaw from "rehype-raw";
import rehypePrism from "rehype-prism-plus";
import rehypeStringify from "rehype-stringify";
import rehypeExternalLinks from "rehype-external-links";
import { PostItem } from "../../lib/types";
import { Metadata, ResolvingMetadata } from "next";
import { getPostData } from "../../lib/functions";

type Props = {
  params: { slug: string };
};

export async function generateMetadata(
  { params }: Props,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const post = await createPostData(params.slug);
  return {
    title: `${post.title} | ブログタイトル`,
    description: `${post.description ?? post.title}`,
  };
}

// 静的ルートの作成
export async function generateStaticParams() {
  const postsDirectory = path.join(process.cwd(), "posts");
  const filenames = fs.readdirSync(postsDirectory);
  const posts = await getPostData();
  return posts.map((post: PostItem) => {
    return {
      path: `/posts/${post.slug}`,
      slug: post.slug,
    };
  });
}

async function createPostData(slug: string): Promise<PostItem> {
  const filePath = path.join(process.cwd(), "posts", `${slug}.md`);
  const fileContents = fs.readFileSync(filePath, "utf8");
  const { data, content } = matter(fileContents);

  const processedContent = await remark()
    .use(html, { sanitize: false })
    .process(content);

  const contentHtml = processedContent.toString();

  const rehypedContent = await rehype()
    .data("settings", { fragment: true })
    .use(rehypeRaw)
    .use(rehypePrism)
    .use(rehypeExternalLinks, { target: "_blank", rel: ["nofollow"] })
    .use(rehypeStringify)
    .process(contentHtml);

  return {
    slug: slug,
    title: data.title,
    description: data.description,
    date: data.date,
    image: data.image,
    tags: data.tags,
    contentHtml: rehypedContent.value.toString(),
  };
}

export default async function Post({ params }: Props) {
  const postData = await createPostData(params.slug);

  return (
    <>
      <div className="max-w-none">
        {postData.image && (
          <div className="flex border justify-center mb-3">
            <picture>
              <img
                src={`${postData.image}`}
                alt={postData.title}
                width={600}
                height={224}
                className="object-contain max-w-full h-auto"
                style={{ maxHeight: "224px" }}
              />
            </picture>
          </div>
        )}
        <h1 className="h2">{postData?.title}</h1>
        <time>{postData?.date}</time>
        <div className="space-x-2">
          {postData?.tags &&
            postData.tags?.map((category) => (
              <span key={category} className="badge bg-secondary">
                <Link href={`/tags/${category}`}>{category}</Link>
              </span>
            ))}
        </div>
        <div className="row">
          <div
            className={"markdown-content col-md-12"}
            dangerouslySetInnerHTML={{ __html: postData.contentHtml }}
          ></div>
        </div>
      </div>
    </>
  );
}

取得したデータを remark、rehype 等のライブラリを使用して Markdown ファイルを HTML 形式にして dangerouslySetInnerHTML でセットしています。

このあたりは他にもパースする方法があるので好きに調べて改造していってください。

これでトップページで記事リンクをクリックすると記事ページが HTML 形式で表示されます。

タグ一覧ページの作成

先ほど作成したページにタグ一覧ページへのリンクを用意しています。

Markdown 内の tags に指定したタグのページが表示できるようにしていきます。

app ディレクトリに「tags」ディレクトリを生成し、その中に「[slug]」というディレクトリを作成ます。

その中に「page.tsx」を作成して下記のようにします。

app/tags/[slug]/page.tsx

import PostCard from "../../../components/PostCard";
import { PostItem } from "../../lib/types";
import { Metadata, ResolvingMetadata } from "next";
import { PageData, createPageData, getPostData, getTagsData } from "../../lib/functions";
import Pagination from "../../../components/Pagination";

type Props = {
  params: { slug: string }
}

export async function generateMetadata({ params }: Props, parent: ResolvingMetadata): Promise<Metadata> {
  const tag = decodeURIComponent(params.slug);
  return {
    title: `${tag} | ブログタイトル`,
    description: `${tag}`,
}

// 静的ルートの作成
export async function generateStaticParams() {
  const allTags = new Set<string>();

  const posts = await getPostData();
  posts.forEach((post: PostItem) => {
    if (post.tags) {
      post.tags.forEach((tag: string) => {
        return allTags.add(encodeURIComponent(tag));
      });
    }
  });

  const params = Array.from(allTags).map((tag) => {
    return {
      path: `/tags/${tag}`,
      slug: tag,
    };
  });

  return params;
}

export default async function TagPage({ params }: { params: { slug: string } }) {
  const posts = await getTagsData(params.slug);

  const pageData: PageData = createPageData(
    1,
    posts.length
  );

  return (
    <>
      <div className="my-8">
        <div className="row">
          {posts.slice(pageData.start, pageData.end).map((post) => (
            <PostCard key={post.title} post={post} />
          ))}
        </div>
        <div className='mb-3'>
          <Pagination type={`tags/${params.slug}`} pages={pageData.pages} currentPage={pageData.currentPage} />
        </div>
      </div>
    </>
  );
}

タグページの作成

タグにもページングを設けたいので app/tags/[slug]/ディレクトリに[page]ディレクトリを追加して下記のようにファイルを追加します。

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

import PostCard from "../../../../components/PostCard";
import { PostItem } from "../../../lib/types";
import { Metadata, ResolvingMetadata } from "next";
import {
  PageData,
  createPageData,
  getPostData,
  getTagsData,
} from "../../../lib/functions";
import Pagination from "../../../../components/Pagination";

type Props = {
  params: { slug: string; page: number };
};

export async function generateMetadata(
  { params }: Props,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const tag = decodeURIComponent(params.slug);
  const title = `${tag} - ${params.page}ページ目 | Nemutai`;
  return {
    title: title,
    description: `${tag}`,
  };
}

// 静的ルートの作成
export async function generateStaticParams() {
  const tagMaps: Record<string, number> = {};
  const posts = await getPostData();
  posts.forEach((post: PostItem) => {
    if (post.tags) {
      post.tags.forEach((tag: string) => {
        tag = encodeURIComponent(tag);
        if (tagMaps[tag]) {
          tagMaps[tag]++;
        } else {
          tagMaps[tag] = 1;
        }
      });
    }
  });

  let params: { path: string; slug: string; page: string }[] = [];

  // ページ数で按分
  for (const key in tagMaps) {
    if (tagMaps.hasOwnProperty(key)) {
      const totalPages = Math.ceil(tagMaps[key] / 1);
      for (let i = 1; i <= totalPages; i++) {
        const routes = {
          path: `/tags/${key}/${i}`,
          slug: `${key}`,
          page: `${i}`,
        };
        params.push(routes);
      }
    }
  }

  return params;
}

export default async function TagPage({
  params,
}: {
  params: { slug: string; page: number };
}) {
  const posts = await getTagsData(params.slug);

  const pageData: PageData = createPageData(params.page, posts.length);

  return (
    <>
      <div className="my-8">
        <div className="row">
          {posts.slice(pageData.start, pageData.end).map((post) => (
            <PostCard key={post.title} post={post} />
          ))}
        </div>
        <div className="mb-3">
          <Pagination
            type={`tags/${params.slug}`}
            pages={pageData.pages}
            currentPage={pageData.currentPage}
          />
        </div>
      </div>
    </>
  );
}

これで post 記事内の/tags/[タグ名]/[ページ数]をクリックでタグページ内のページングが可能になります。

記事内レイアウトの装飾

現状のままだと記事内のスタイルがイマイチなので h1,h2 や pre 要素にスタイルが適用されるようにしていきます。

npm install -D @tailwindcss/typography

等とし、パッケージを追加し、tailwind.config.js の plugins に

plugins: [require('@tailwindcss/typography')],

を追加していきます。

最終的に下記のような形になります。

tailwind.condfig.js

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./app/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [require("@tailwindcss/typography")],
};

content 内も app ディレクトリが指定されているのを確認

追加したら、app/posts/[slug]/page.tsx の markdown コンテンツ出力部分に下記のように prose クラスを追加します。

<div className="row">
  <div
    className={"markdown-content prose col-md-12"}
    dangerouslySetInnerHTML={{ __html: postData.contentHtml }}
  ></div>
</div>

これでそれっぽいスタイルが適用されるかと思います。

globals.css に.markdown-content のスタイルを追加して自分の好きなようにスタイルの微調整を行ったりすれば良いかと思います。

最後に

これで posts ディレクトリ内に Markdown ファイルを追加して記事を追加していくだけです。

このあたり Hugo でやってたように Markdown ファイルの作成コマンドなどもあれば良いのかなといった感じです。

静的ページの場合、generateStaticParams 関数であらかじめ接続先のルーティングを定義する必要があるので、

今回のようなファイルベースのルーティングだといかに処理を効率的に実装できるかで今後の拡張しやすさが変わってくるかと思います。

MetadataApi を利用してページ部分の meta タグの生成も行っていますが、こちらもテンプレート化しておいたほうが良いケースも出て来ると思いますし、

カスタマイズしていく楽しみはあるでしょう。

Markdown の表示は今回一旦 parse してから表示するようにしていますが、react-markdown を利用したほうがきれいに記述できたりするので色々工夫しても良さそうです。

NextJS の更新が著しいものがあるので今回の対応がいつまで有効な手段なのかわからないところが懸念点です・・・。