sagara.inkITエンジニアのまとめノート

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の導入方法

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

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

console
npm create-next-app my-blog

手動で作成する

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

console
mkdir my-blog && cd my-blog
npm init -f
npm install next react react-dom

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

console
mkdir pages
touch pages/index.js
index.js
import React from 'react'

export default function index() {
    return (
        <h1>Hello, Next.js</h1>
    )
}

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

console
npx next dev
fig1

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

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

console
npx next build && npx next export

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

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

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

構成例
nextjs-blog/
  ├ components/
  |   ├ layouts/
  │   │   ├ Header.tsx
  │   │   └ Footer.tsx
  |   └ pages/ 
  │       ├ Article.tsx
  │       ├ List.tsx
  │       └ Home.tsx
  ├ pages/
  │   └ article/
  │       ├ 20220101.tsx
  │       ├ 20220202.tsx
  │       └ index.tsx
  ├ helpers/
  |   └ getArticleInfo.tsx 
  ├ .env.develoment
  ├ .env.production
  └ package.json

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

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

記事ページ

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

components/pages/Article.tsx
import React from 'react'

export interface ArticleInformationInterface {
  uri: string
  title: string
  published: string
  tags?: string[]
}

export default function Article ({ children, info }: {
    children?: React.ReactElement[],
    info: ArticleInformationInterface
  }) {
  return (
    <>
      <section>
        <span>{info.published}</span>
        <h1>{info.title}</h1>
        { children }
      </section>
    </>
  )
}

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

pages/article/20220101.tsx
import React from 'react'
import Article, {
  ArticleInformationInterface
} from '@/components/pages/Article'

export const info: ArticleInformationInterface = {
  uri: '/article/20220101',
  title: '記事のタイトル',
  published: '2021-01-01',
  tags: ['HTML', 'デザイン'],
}

export default function Content () {
  return (
    <Article info={info}>
      <p>
        記事本文・・・
      </p>
    </Article>
  )
}

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

Dynamic Routes | Learn Next.js

一覧ページ

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

components/pages/List.tsx
import React from 'react'
import Link from 'next/link'
import { ArticleInformationInterface } from '@/components/pages/Article/Article'

export default function List ({
  list
}: {
  list: ArticleInformationInterface[],
}) {
  return (
    <>
      <section>
        <ul>
        { list && list.map((e: ArticleInformationInterface) => {
          return (
            <li key={e.uri} className={styles.shadow}>
              <Link href={e.uri} key={e.uri}><a></a></Link>
              <span>{e.published}</span>
              <h3>{e.title}</h3>
              <div className={styles.tags}>
                {e.tags?.join(', ')}
              </div>
            </li>
          )
        })
        </ul>
      </section>
    </>
  )
}

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

helpers/getArticleInfo.tsx
import { ArticleInformationInterface } from '@/components/pages/Article'
import path from 'path'
import fs from 'fs'

export const getArticleInfo = async (dirPath: string)
: Promise<ArticleInformationInterface[]> => {
  const baseDir = path.join(process.cwd(), `pages/${dirPath}`)
  const infoList: ArticleInformationInterface[] = []
  const files = await fs.readdirSync(baseDir)
  files.map(async file => {
    if (file === 'index.tsx') {
      return
    }
    const info = await require(`/pages/${dirPath}/${file}`)
    infoList.push(info.info)
    return true
  })
  return infoList
}

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

/pages/index.tsx
import React from 'react'
import { GetStaticProps, InferGetStaticPropsType } from 'next'
import List from '@/components/pages/List'
import { getArticleInfo } from '@/helpers/ArticleInfo'

export const getStaticProps: GetStaticProps = async () => {
  const articleInfo = await getArticleInfo('article')
  return {
    props: {
      articleInfo,
    }
  }
}

export default function ListPage (
  props: InferGetStaticPropsType<typeof getStaticProps>
) {
  return (
      <List
        articleInfoList={props.articleInfo}
      />
  )
}

タグ機能

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

components/pages/List.tsx
import React, { useState } from 'react'
import { ArticleInformationInterface } from '@/components/pages/Article/Article'
import _ from 'lodash'

export default function List ({
  list,
  allTags,
}: {
  list: ArticleInformationInterface[],
  allTags?: {key: string, count: number}[],
}) {
  const [query, setQuery] = useState('')
  const filteredArticleInfo = filterArticleInfo(list, query)
  return (
    <>
      <section>
        <div>
          <div>{ query.tag && <span>タグ: {query.tag}</span> }</div>
          <ul>
          { list &&
            list.map((e: ArticleInformationInterface) => {
            return (
              <li key={e.uri}>
                <Link href={e.uri} key={e.uri}><a></a></Link>
                <span>{e.published}</span>
                <h3>{e.title}</h3>
                <div>{e.tags?.join(', ')}</div>
              </li>
            )
          })
          </ul>
        </div>
        <div>
          {allTags && <>
            <h3>タグ</h3>
            <ul>
            { allTags?.map(tag => {
              return (
                <li key={tag.key}>
                  <a href="#" onClick={() => setQuery( tag.key )}>
                    {tag.key} ({tag.count})
                  </a>
                </li>
              )})}
            </ul>
          </>
          }
        </div>
      </section>
    </>
  )
}

const filterArticleInfo = (
  articleInfo: ArticleInformationInterface[],
  query: { tag: string }
) => {
  const filteredArticleInfo: ArticleInformationInterface[] = _
    .filter(articleInfo,
      (articleInfo: ArticleInformationInterface) => {
        if (!articleInfo.tags) return true
        if (!query.tag.length) return true
        return _(articleInfo.tags).includes(query.tag)
      })
  return _.orderBy(filteredArticleInfo, 'published', 'desc')
}

サイトマップ

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

next-sitemap - npm