2021-10-31 投稿 2021-07-03 更新

Next.jsのSSGで個人ブログを作成する

シングルページアプリケーション(SPA)の弱点として、クローラによっては描画内容に到達できなかったり、 JavaScriptで描画するために、静的なページと比べると処理速度の問題があったりします。 そこで、Webページをサーバー側でレンダリング(SSR)したり、静的サイトを生成したり(SSG)する方法が編み出されました。 ここでは、ReactベースのフレームワークであるNext.jsのSSGを使って、個人のWebサイトを生成したいと思います。

ブログにNext.jsを導入する理由

ブログを作る場合にNext.jsを使うと良い理由を書いていきます。

WordPressと比較した場合

ブログを作成する際のフレームワークとしては、長らくWordPressが筆頭に上がっていますが、 データベースが動く環境が必要なため、環境構築のコストはやや高いです。

記事の内容がデータベース内に保存されるので、内部的にSQLを使えることにより記事の検索機能に強みがありますが、 データベースのバックアップや冗長化などに配慮する必要があります。

対するNext.jsのSSGでは検索機能に弱みはあるものの、 静的HTMLとして出力できるため、表示速度は上がります。

  • Next.jsはデータベースの準備が不要
  • 記事のバージョン管理がしやすい、保守性がある
  • 静的ページのためパフォーマンスが良い

Reactと比較した場合

素のReactは、動的なアプリケーション(SPA)を作るのに向いていますが、 ブログを作る場合、最終的に必要なのは静的なページです。こういった用途では、Next.jsの機能であるSSGが役立ちます。

Reactで開発するなら、ページの状態遷移などにReactRouterなどを使う必要がありますが、 Next.jsではそういった機能があらかじめフレームワークに組み込まれています。 ディレクトリ構造と静的サイトになるURLが対応することにより、 pagesというディレクトリにページ用のコンポーネントを追加するだけで、直感的に静的サイトを開発することができます。

  • JSXに慣れていればスムーズに導入できる
  • ディレクトリ構造と静的サイトになるURLが対応している

Next.jsの導入方法

公式チュートリアルから始める

公式のチュートリアル がとてもわかりやすいので、おすすめです。

npm create-next-app my-blog

手動で作成する

create-next-appを使わずに一からセットアップする場合は、次のようにします。

1mkdir my-blog && cd my-blog
2npm init -f
3npm install next react react-dom

Next.jsにはpagesディレクトリが必要です。このディレクトリ配下に、 ページの内容となるJSXコンポーネントを配置していきます。

mkdir pages touch pages/index.js

1import React from 'react'
2
3export default function index() {
4    return (
5        <h1>Hello, Next.js</h1>
6    )
7}

以下で開発用サーバーを立ち上げることができます。

npx next dev

fig1

このように、pagesディレクトリ配下に、静的サイト作成でHTMLを配置するのと同じようにコンポーネントを配置すれば、 Next.jsがそれをレンダリングしてくれます。

本番用の静的サイトを生成する際には、以下のコマンドを実行します。

npx next build && npx next export

デフォルトでは、Next.jsプロジェクト直下のoutディレクトリ配下にHTMLが生成されます。

Next.jsでブログを作成する

ブログを構成する主要な機能の実装方法について説明します。

1nextjs-blog/
2  ├ components/
3  |   ├ layouts/
4  │   │   ├ Header.tsx
5  │   │   └ Footer.tsx
6  |   └ pages/ 
7  │       ├ Article.tsx
8  │       ├ List.tsx
9  │       └ Home.tsx
10  ├ pages/
11  │   └ article/
12  │       ├ 20220101.tsx
13  │       ├ 20220202.tsx
14  │       └ index.tsx
15  ├ helpers/
16  |   └ getArticleInfo.tsx 
17  ├ .env.develoment
18  ├ .env.production
19  └ package.json

あらかじめ用意されているpagesディレクトリの中に、実際のページの元となるコンポーネントを配置します。 レイアウト用のファイルなどは別途componentsディレクトリなどを作成しその中に置いておきます。

なお上記はTypeScriptを利用する想定なので拡張子がtsxになっていますが、JavaScriptでjsxを利用することももちろんできます。 またCSSなどスタイルに関するファイルや記述は省略しています。

記事を構成する情報をArticleInformationInterfaceにまとめています。 これは一覧画面に記事を表示するのに必要な情報です。 記事の各ページ(pages/article/***.tsx)でこの型のデータを定義する必要があります。

1import React from 'react'
2
3export interface ArticleInformationInterface {
4  uri: string
5  title: string
6  published: string
7  tags?: string[]
8}
9
10export default function Article ({ children, info }: {
11    children?: React.ReactElement[],
12    info: ArticleInformationInterface
13  }) {
14  return (
15    <>
16      <section>
17        <span>{info.published}</span>
18        <h1>{info.title}</h1>
19        { children }
20      </section>
21    </>
22  )
23}

pages/配下に置く実際の記事ページは以下のようになります。

1`import React from 'react'
2import Article, {
3  ArticleInformationInterface
4} from '@/components/pages/Article'
5
6export const info: ArticleInformationInterface = {
7  uri: '/article/20220101',
8  title: '記事のタイトル',
9  published: '2021-01-01',
10  tags: ['HTML', 'デザイン'],
11}
12
13export default function Content () {
14  return (
15    <Article info={info}>
16      <p>
17        記事本文・・・
18      </p>
19    </Article>
20  )
21}

[id].jsのようなファイル名にして動的にmarkdownなどの外部ファイルを取得することもできます。 Dynamic Routes | Learn Next.js

一覧ページ

一覧ページは上記で定義したArticleInformationInterface型のデータを読み込み、それを一覧で表示します。

1import React from 'react'
2import Link from 'next/link'
3import { ArticleInformationInterface } from '@/components/pages/Article/Article'
4
5export default function List ({
6  list
7}: {
8  list: ArticleInformationInterface[],
9}) {
10  return (
11    <>
12      <section>
13        <ul>
14        { list && list.map((e: ArticleInformationInterface) => {
15          return (
16            <li key={e.uri} className={styles.shadow}>
17              <Link href={e.uri} key={e.uri}><a></a></Link>
18              <span>{e.published}</span>
19              <h3>{e.title}</h3>
20              <div className={styles.tags}>
21                {e.tags?.join(', ')}
22              </div>
23            </li>
24          )
25        })
26        </ul>
27      </section>
28    </>
29  )
30}

ところで、一覧ページのデータを表示するためには、 Next.jsの記事(pages/article)ディレクトリ配下にあるtsxファイルからArticleInformationInterface型のデータを取り出す必要があるのですが、 そのコードが以下のようなものになります。

1import { ArticleInformationInterface } from '@/components/pages/Article'
2import path from 'path'
3import fs from 'fs'
4
5export const getArticleInfo = async (dirPath: string)
6: Promise<ArticleInformationInterface[]> => {
7  const baseDir = path.join(process.cwd(), \`pages/\${dirPath}\`)
8  const infoList: ArticleInformationInterface[] = []
9  const files = await fs.readdirSync(baseDir)
10  files.map(async file => {
11    if (file === 'index.tsx') {
12      return
13    }
14    const info = await require(\`/pages/\${dirPath}/\${file}\`)
15    infoList.push(info.info)
16    return true
17  })
18  return infoList
19}

このメソッドをNext.jsの/pagesディレクトリ配下で使えるgetStaticProps()の中で呼ぶことで記事一覧を表示するために必要な情報が取得できます。

1import React from 'react'
2import { GetStaticProps, InferGetStaticPropsType } from 'next'
3import List from '@/components/pages/List'
4import { getArticleInfo } from '@/helpers/ArticleInfo'
5
6export const getStaticProps: GetStaticProps = async () => {
7  const articleInfo = await getArticleInfo('article')
8  return {
9    props: {
10      articleInfo,
11    }
12  }
13}
14
15export default function ListPage (
16  props: InferGetStaticPropsType<typeof getStaticProps>
17) {
18  return (
19      <List
20        articleInfoList={props.articleInfo}
21      />
22  )
23}

タグ機能

タグによるフィルター機能を作ることもできます。考え方としてはgetArticleInfo.tsxで取得したarticleInfoをJavaScriptでフィルターしてあげれば良いです。 強力なReactの機能により、比較的簡単にフィルター処理は書けるでしょう。

1import React, { useState } from 'react'
2import { ArticleInformationInterface } from '@/components/pages/Article/Article'
3import _ from 'lodash'
4
5export default function List ({
6  list,
7  allTags,
8}: {
9  list: ArticleInformationInterface[],
10  allTags?: {key: string, count: number}[],
11}) {
12  const [query, setQuery] = useState('')
13  const filteredArticleInfo = filterArticleInfo(list, query)
14  return (
15    <>
16      <section>
17        <div>
18          <div>{ query.tag && <span>タグ: {query.tag}</span> }</div>
19          <ul>
20          { list &&
21            list.map((e: ArticleInformationInterface) => {
22            return (
23              <li key={e.uri}>
24                <Link href={e.uri} key={e.uri}><a></a></Link>
25                <span>{e.published}</span>
26                <h3>{e.title}</h3>
27                <div>{e.tags?.join(', ')}</div>
28              </li>
29            )
30          })
31          </ul>
32        </div>
33        <div>
34          {allTags && <>
35            <h3>タグ</h3>
36            <ul>
37            { allTags?.map(tag => {
38              return (
39                <li key={tag.key}>
40                  <a href="#" onClick={() => setQuery( tag.key )}>
41                    {tag.key} ({tag.count})
42                  </a>
43                </li>
44              )})}
45            </ul>
46          </>
47          }
48        </div>
49      </section>
50    </>
51  )
52}
53
54const filterArticleInfo = (
55  articleInfo: ArticleInformationInterface[],
56  query: { tag: string }
57) => {
58  const filteredArticleInfo: ArticleInformationInterface[] = _
59    .filter(articleInfo,
60      (articleInfo: ArticleInformationInterface) => {
61        if (!articleInfo.tags) return true
62        if (!query.tag.length) return true
63        return _(articleInfo.tags).includes(query.tag)
64      })
65  return _.orderBy(filteredArticleInfo, 'published', 'desc')
66}

サイトマップ

サイトマップ(sitemap.xml)の生成にはnext-sitemapという便利なパッケージがあります。これを使うのもいいでしょう。

next-sitemap - npm