Next.js を使った Jamstack なブログの始め方

JavaScriptJamstackSSGReactNext.js

Next.js

今回は Next.js を使って Jamstack なブログを作る方法を紹介します。

ゴール

本チュートリアルでは最終的に以下の構成のブログサイトを作ります。

ページ構成

/ フロントページ
├ /archive (なし)
  └ /archive/[page] 投稿一覧ページ
└ /posts (なし)
  └ /posts/[slug] 投稿詳細ページ

「フロントページ」「投稿一覧ページ」「投稿詳細ページ」の 3 つだけのシンプルな構成です。

投稿の管理方法

  • frontmatter 付きの Markdown ファイル

利用技術 / サービス

  • サイト生成: Next.js
  • コードホスティング: GitHub
  • ビルド・ホスティング: Netlify
  • その他: Google Fonts / Google Analytics

リポジトリ

今回作成したサンプルは GitHub 上に置いているので興味のある方は覗いてみてください。

想定読者

HTML / CSS / JavaScript / Node / React をある程度知っていて Git が使える方を読者として想定しています。

本記事は、大まかな流れをつかむだけであれば単体で読んでいただいて大丈夫ですが、細かく追っていく場合は Next.js の公式ドキュメントとあわせて読んでいただくのがよいと思います。

前提条件

Node / React / Next.js の以下のバージョンを使用します。

  • Node: v14.5.0
  • React: 16.13.1
  • Next.js: 9.4.4

最初に Next.js についてかんたんに説明します。

Next.js とは

Next.js は React ベースの SSR / SSG フレームワークです。 SSR は Server Side Rendering (サーバーサイドレンダリング)、 SSG は Static Site Generation (スタティックサイトジェネレーション)の略です。 SSR と SSG の違いをかんたんに説明すると、 SSR はウェブブラウザからリクエストが来たときに動的に HTML を生成するもので、 SSG は事前のビルド時に HTML ファイルを生成するものです。

専用のスターターコマンドを使って土台となるファイル群を生成して、所定の構成でファイルを設置すれば、 next dev / next export 等のシンプルなコマンドで開発サーバーの立ち上げや静的サイトの生成(「ビルド」)がかんたんに行えます。

Next.js には他の静的サイトジェネレーターと比較して次のような特徴があります。

  1. React ベースである
  2. SSG だけでなく SSR にも対応している
  3. 利用者が多い

Next.js は、シンプルな静的サイトジェネレーターとしても、動的な機能を提供する SSR フレームワークとしても利用できます。

おそらく Next.js と Gatsby の 2 つが現在の Jamstack の文脈で最も多く使われているフレームワークです(国内に限定すると Vue.js ベースの Nuxt.js の方がシェアが高いかもしれません)。 ちなみに、 Gatsby も Next.js と同じ React ベースの静的サイトジェネレーターです。 Next.js と Gatsby の違いに興味がある方には次の記事がお役に立つと思います。

そもそも「静的サイトジェネレーター」「 Jamstack 」が何なのかを押さえておきたい方には、別ブログですが次の記事がご参考になるかと思います。

尚、今回は Next.js の SSG の機能のみを使用し、 SSR 機能は使用しません。

では実際にブログを作っていきます。

Next.js でブログを作る手順

  1. プロジェクトの土台を作成する
  2. 開発サーバーを立ち上げる
  3. 主要なページを作る
  4. デプロイの準備をする
  5. デプロイ

1. プロジェクトの土台を作成する

まず最初に npx create-next-app コマンドを使ってリポジトリを作成します。

npx create-next-app
# or
yarn create next-app

コマンドを実行すると生成するプロジェクトの名前(=リポジトリ名)を聞かれるので入力します。 説明をわかりやすくするため、プロジェクト名を nextjs-blog-example-ja にした想定で以下説明していきます。

npx create-next-app

npx: 1個のパッケージを1.253秒でインストールしました。
✔ What is your project named? … nextjs-blog-example-ja
✔ Pick a template › Default starter app
Creating a new Next.js app in /path/to/nextjs-blog-example-ja.

Installing react, react-dom, and next using npm...

(中略)

Initialized a git repository.

Success! Created nextjs-blog-example-ja at /path/to/nextjs-blog-example-ja
Inside that directory, you can run several commands:

  npm run dev
    Starts the development server.

  npm run build
    Builds the app for production.

  npm start
    Runs the built app in production mode.

We suggest that you begin by typing:

  cd nextjs-blog-example-ja
  npm run dev

参考:

2. 開発サーバーを立ち上げる

上のコマンドが無事に終了したら、コマンドを実行したディレクトリの下にプロジェクト名と同じ名前のディレクトリが作られています。 その下に移動して npm run dev を実行します。

cd nextjs-blog-example-ja
npm run dev

コマンドを実行すると以下のようなメッセージが表示されて開発サーバーが立ち上がります。

npm run dev

> nextjs-blog-example-ja@0.1.0 dev /path/to/project/nextjs-blog-example-ja
> next dev

ready - started server on http://localhost:3000
event - compiled successfully
event - build page: /next/dist/pages/_error
wait  - compiling...
event - compiled successfully
event - build page: /
wait  - compiling...
event - compiled successfully

ブラウザで表示されている URL (私の環境では http://localhost:3000 )にアクセスすると、 Next.js なんちゃらと書かれたダミーページが開くはずです。 万が一表示されない場合は、ブラウザのクッキーやキャッシュが悪さをしていることがあるので、そのあたりをチェックするとよいでしょう。

開発サーバーの確認ができたら、コマンドを終了して開発サーバーを停止します( macOS の場合は ctrl + c )。

ちなみに、 npm run ... という形で実行できるコマンドはファイル package.jsonscripts で定義されています。 記事執筆時点では次のようになっていました。

{
  ...
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  ...
}

これでプロジェクトのひながたができました。 これを土台にここから実際にブログを作っていきます。 このタイミングで、コードを push する先の GitHub リポジトリも作っておくとよいと思います。

参考:

3. 主要なページを作る

不要なファイルを削除する

プロジェクト直下には以下のディレクトリ・ファイルが作られています。

├ .git/
├ node_modules/
├ pages/
├ public/
├ .gitignore
├ package-lock.json
├ package.json
└ README.md

このうち、今回は使用しない不要なファイルを削除します。

rm -r pages/api/
rm public/vercel.svg
  • pages/api/: API エンドポイントを定義するためのファイルの置き場所です。 SSR 機能を使うときに利用できるものですが、今回は SSR 機能は使わないので削除します。
  • public/vercel.svg: 初期のダミーページで使われている画像です。こちらも不要なので削除します。

全ページ共通のテンプレートを作る

続いて、ヘッダー・メイン・フッターの構成を全ページで共通化させるために Layout コンポーネントを作成します。 これは絶対に必要なものではありませんが作っておくと便利なので、おそらく作るのが一般的です( Layout という名前にしないといけないわけではありません)。

components/Layout.js:

import Head from "next/head"
import Link from "next/link"

const Layout = (props) => {
  const { title, children } = props
  const siteTitle = "後藤のブログ"

  return (
    <div className="page">
      <Head>
        <title>{title ? `${title} | ${siteTitle}` : siteTitle}</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <header>
        <h1 className="site-title">
          <Link href="/">
            <a>{siteTitle}</a>
          </Link>
        </h1>
      </header>

      <main>
        {title ? <h1 className="page-title">{title}</h1> : ``}
        <div className="page-main">
          {children}
        </div>
      </main>

      <footer>
        &copy; {siteTitle}
      </footer>

      <style jsx>{`
        (ここに CSS を記述します)
      `}</style>

      <style jsx global>{`
        (ここに CSS を記述します)
      `}</style>
    </div>
  )
}

export default Layout

この Layout コンポーネントには titlechildren を渡して使います。 試しに pages/index.js で利用してみましょう。 スターターで作られたファイルの中身を次の内容に書き換えます。

pages/index.js:

import Layout from "../components/Layout"

export default function Home(props) {
  return (
    <Layout title="ホーム">
      <div>こんにちは</div>
    </Layout>
  )
}

ファイルを保存した後に開発サーバーのフロントページ(私の環境では http://localhost:3000/ )をブラウザで開くと Layout が反映されていることが確認できるはずです。

少し見やすくするために Layout にスタイルを追加しておきましょう。

import Head from "next/head"
import Link from "next/link"

const Layout = (props) => {
  const { title, children } = props
  const siteTitle = "後藤のブログ"

  return (
    <div className="page">
      <Head>
        <title>{title ? `${title} | ${siteTitle}` : siteTitle}</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <header>
        <h1 className="site-title">
          <Link href="/">
            <a>{siteTitle}</a>
          </Link>
        </h1>
      </header>

      <main>
        {title ? <h1 className="page-title">{title}</h1> : ``}
        <div className="page-main">
          {children}
        </div>
      </main>

      <footer>
        &copy; {siteTitle}
      </footer>

      <style jsx>{`
        .page {
          padding: 2em 1em;
          max-width: 800px;
          margin-left: auto;
          margin-right: auto;
        }

        header {
          display: flex;
          justify-content: center;
          align-items: center;
          margin: 0 0 4em;
        }

        .site-title a {
          color: inherit;
          text-decoration: none;
        }

        footer {
          margin-top: 4em;
          padding-top: 2em;
          padding-bottom: 2em;
          display: flex;
          justify-content: center;
          align-items: center;
        }
      `}</style>

      <style jsx global>{`
        html,
        body {
          padding: 0;
          margin: 0;
          font-family: 'Noto Sans JP', -apple-system, "Segoe UI", "Helvetica Neue",
            "Hiragino Kaku Gothic ProN", メイリオ, meiryo, sans-serif;
          color: #222;
        }

        img,
        iframe {
          max-width: 100%;
        }

        h1, h2, h3, h4, h5, h6 {
          font-family: Montserrat, -apple-system, "Segoe UI", "Helvetica Neue",
            "Hiragino Kaku Gothic ProN", メイリオ, meiryo, sans-serif;
        }

        * {
          box-sizing: border-box;
        }
      `}</style>
    </div>
  )
}

export default Layout

ここでは <style jsx> のブロックを 2 つ追加しました。 global が付いていない <style jsx>...</style> の方は、 Layout コンポーネントそのもののスタイルを指定するためのものです。 global が付いている <style jsx global>...</style> の方は、他のコンポーネントにも適用されるグローバルなスタイルを指定するためのものです。

スタイルの指定方法は他にもあるので公式ドキュメントもあわせてチェックしてください:

尚、ここでは Google Fonts の Noto Sans JPMontserrat と使うつもりで font-family の値を指定していますが、 Google Fonts は後ほど読み込むのでまだここでは有効にはなりません。

これでサイト全体に共通のヘッダー・メイン・フッターのテンプレートができました。 今回はやりませんが、サイドバーウィジェット等を置きたい場合は、適宜コンポーネントを定義して Layout で描画してやるとよいでしょう。

投稿の詳細ページを作る

続いて、投稿の詳細ページを作ります。

投稿は frontmatter 付きの Markdown ファイルで書く形にしたいので、「 frontmatter 付きの Markdown ファイルをパースしてそれをページとして描画する」という処理を書いていく必要があります。

実際にコードを書く前に Next.js でページを作成する方法についてかんたんに確認しておきましょう。

Next.js でページを作成するには、 プロジェクトルート直下の pages/ ディレクトリの下に .js ファイルを設置します。 各 .js ファイルの pages/ からの相対パスがそのままサイト上でのパスになります(ただし、拡張子 .js は除去されます)。

設置するファイルと生成されるページのパスの例:

  • pages/about.js/about
  • pages/contact.js/contact

.js ファイルからは、そのページに対応する React コンポーネントを export default でエクスポートします。

例:

pages/about.js:

const About = () => {
  return <div>ブログを書いています。</div>
}

export default About

1 つのファイルから複数のページをまとめて作りたい場合は、ファイル名の先頭と末尾に [] を付けます。

例:

  • pages/posts/[slug].js/posts/hello/posts/potato

実際に [slug] に入る値はそのファイルの中で getStaticPaths() という async 関数を定義してその戻り値で指定します。 具体例はこれからブログ投稿の詳細ページを作っていくのでそこで確認してください。

投稿の詳細ページの作成の説明に戻ります。

冒頭の ゴール で示したとおり、今回は /posts/hello /posts/potato 等のパスで投稿の詳細ページが見れるようにしたいので、 pages/posts/[slug].js にファイルを作成します。

ファイルの中身は次のとおりにします。

pages/posts/[slug].js:

import fs from "fs"
import path from "path"

import Layout from "../../components/Layout"

export default function Post(params) {
  return (
    <Layout title={params.title}>
      <div className="post-meta">
        <span>{params.published}</span>
      </div>
      <div className="post-body"
        dangerouslySetInnerHTML={{ __html: params.content }}
      />
    </Layout>
  )
}

/**
 * ページコンポーネントで使用する値を用意する
 */
export async function getStaticProps({ params }) {
  const content = await readContentFile({ fs, slug: params.slug })

  return {
    props: {
      ...content
    }
  }
}

/**
 * 有効な URL パラメータを全件返す
 */
export async function getStaticPaths() {
  const paths = listContentFiles({ fs })
    .map((filename) => ({
      params: {
        slug: path.parse(filename).name,
      }
    }))

  return { paths, fallback: false }
}

テンプレートファイルを使ってページをまとめて作る場合は、ページに相当する React コンポーネントを export default でエクスポートするのに加えて、 getStaticPaths()getStaticProps() というふたつの async 関数を定義して export する必要があります。

getStaticPaths(): 有効な URL パラメータの値を定義したオブジェクトを返す必要があります( URL パラメータというのは pages/posts/[slug].js の場合は slug を意味します)。 オブジェクトには pathsfallback という 2 つのキーを持たせる必要があります。

例:

export async function getStaticPaths() {
  return {
    paths: [
      { params: { slug: 'hello' } },
      { params: { slug: 'potato' } },
    ],
    fallback: false,
  }
}

paths には有効な URL パラメータを指定したオブジェクトの配列、 fallback には paths で指定されなかったパスに対して 404 を返すかどうかを表すフラグの真偽値を渡します。 fallbackfalse を指定すると paths で指定されなかったパスに対して 404 を返します。 true を指定すると paths で指定されなかったパスに対して動的に getStaticProps を実行してページを返そうとします。 Next.js を静的サイトジェネレーターとしてだけ使う場合は false 、 SSR もさせたい場合は true を指定します。

今回は [slug].js というファイル名にしていますが、これは別の名前にすることもできます。 公式サイトでは [id].js という例がよく使われていますし、ページの内容に合わせて [year].js[tag].js といった、開発者自身がわかりやすい名前にするとよいと思います。

getStaticProps(): getStaticPaths() から返された paths の要素をひとつ受け取り、そのページに対応するページコンポーネントに渡すパラメータを格納したオブジェクトを返す関数を定義します。

例:

export async function getStaticProps({ params }) {
  const content = await readContentFile(params.slug)

  return {
    props: {
      title: content.title,
      published: content.published,
      body: content.body,
    },
  }
}

戻り値は props というキーを持ったオブジェクトです。 props は JSON 化されるので、 JSON でサポートされている型以外を値の中に使うことはできません。 具体的には、たとえば undefinedDate 型の変数は使用できません。

ビルド時には、 getStaticPaths() は全体で一度だけ、 getStaticProps() は各ページに対して一度ずつ呼ばれます。

余談ですが、 getStaticPaths()getStaticProps() はパッと見で見分けづらく、書き間違えたり読み間違えたりすることがあるので、将来的にもう少し見分けやすい名前にしてもらえればなぁと思います。

pages/ 以下にファイルを設置すると通常は開発サーバー上ですぐにページが作られますが、上のコードでは readContentFile()listContentFiles() が未定義なのでこのページはまだ動きません(エラーが発生します)。

これらの関数の中身は後ほど実装していきますが、道のりが少し長くなるので、ここではダミーデータを返す仮の定義をしておきましょう。 同ファイル内で次のような関数の定義を追加しておけば、開発サーバーのパス /posts/taketori でダミーのページを確認できるようになります:

pages/posts/[slug].js:

// 上の `pages/posts/[slug].js` の中身に以下を追記する

async function readContentFile({ fs, slug }) {
  return {
    title: "竹取物語",
    published: "2020/07/23",
    content: "今は昔竹取の翁といふものありけり。野山にまじりて、竹をとりつゝ、萬の事につかひけり。",
  }
}

function listContentFiles({ fs }) {
  return ["taketori.md"]
}

ダミーページが確認できたら、実際に Markdown ファイルを読み込むための readContentFile()listContentFiles() を実装していきましょう。

Markdown ファイルをパースする機能を作る

最初に frontmatter 付きの Markdown のパースに有用なライブラリをインストールします。

npm i --save remark remark-html gray-matter

このあたりは多くの選択肢がありますが、今回は remark remark-html gray-matter の 3 つを使うことにします。

インストールが完了したら、 lib/content-loader.js というファイルを作成して実際の処理を書いていきます。

lib/content-loader.js:

import path from "path"

import remark from "remark"
import html from "remark-html"
import matter from "gray-matter"

import { formatDate } from "./date"

const DIR = path.join(process.cwd(), "content/posts")
const EXTENSION = ".md"

/**
 * Markdown のファイル一覧を取得する
 */
const listContentFiles = ({ fs }) => {
  const filenames = fs.readdirSync(DIR)
  return filenames
    .filter((filename) => path.parse(filename).ext === EXTENSION)
}

/**
 * Markdown のファイルの中身をパースして取得する
 */
const readContentFile = async ({ fs, slug, filename }) => {
  if (slug === undefined) {
    slug = path.parse(filename).name
  }
  const raw = fs.readFileSync(path.join(DIR, `${slug}${EXTENSION}`), 'utf8')
  const matterResult = matter(raw)

  const { title, published: rawPublished } = matterResult.data

  const parsedContent = await remark()
    .use(html)
    .process(matterResult.content)
  const content = parsedContent.toString()

  return {
    title,
    published: formatDate(rawPublished),
    content,
    slug,
  }
}

export { listContentFiles, readContentFile }

少し長いですが、やっていることはシンプルです。

  • listContentFiles(): content/posts/ 以下の拡張子が .md のファイルの名前を全件返す
  • readContentFile(): content/posts/ 以下の .md ファイル 1 件を frontmatter 付きの Markdown として読み込む

import している formatDate の中身は次のとおりです。 こちらも lib/ 以下に作成します。

lib/date.js:

const formatDate = (date) => {
  if (!(date instanceof Date)) {
    return ''
  }

  let year = date.getFullYear()
  let month = (date.getMonth() < 9 ? '0' : '') + (date.getMonth() + 1)
  let day = (date.getDate() < 10 ? '0' : '') + date.getDate()

  return `${year}/${month}/${day}`
}

export { formatDate }

投稿の詳細ページを作る

これらのファイルを保存したら、先に作った pages/posts/[slug].js ファイルの中身を更新します。

pages/posts/[slug].js:

import fs from "fs"
import path from "path"

import Layout from "../../components/Layout"
import { listContentFiles, readContentFile } from "../../lib/content-loader"

// (中略)

/**
 * ページコンポーネントで使用する値を用意する
 */
export async function getStaticProps({ params }) {
  const content = await readContentFile({ fs, slug: params.slug })

  return {
    props: {
      ...content
    }
  }
}

/**
 * 有効な URL パラメータを全件返す
 */
export async function getStaticPaths() {
  const paths = listContentFiles({ fs })
    .map((filename) => ({
      params: {
        slug: path.parse(filename).name,
      }
    }))

  return { paths, fallback: false }
}

// 先ほど作った、ダミー投稿を返す
// `listContentFiles()` と `readContentFile()` は削除する

ポイントは次の行です。

import { listContentFiles, readContentFile } from "../../lib/content-loader"

これで Markdown で書かれた投稿ファイルをページに表示する仕組みが整ったので、試しに content/posts/ 以下に投稿の .md ファイルを作成してみましょう。

content/posts/hello.md:

---
title: ブログを始めます
published: 2020-07-12
---

これからブログを始めます。

frontmatter には titlepublished の 2 つの要素を含めることができます( frontmatter を知らない方のために説明すると、 frontmatter とは Markdown ファイルの先頭に、上のような所定のフォーマットで記述されたメタ情報のことです)。

このファイルを保存した後に、開発サーバーを走らせてブラウザでパス /posts/hello にアクセスすると、この投稿ファイルがページに描画されことが確認できます。

フロントページを作る

詳細ページが作成できたので、続いてフロントページ(ホームページ)を「最新の投稿一覧」を表示する形に変更しましょう。 pages/index.js の中身を次のとおりに変更します。

pages/index.js:

import fs from "fs"

import Link from "next/link"

import Layout from "../components/Layout"
import { readContentFiles } from "../lib/content-loader"

export default function Home(props) {
  const { posts } = props
  return (
    <Layout title="">
      {posts.map((post) => <div
        key={post.slug}
        className="post-teaser"
      >
        <h2><Link href="/posts/[id]" as={`/posts/${post.slug}`}><a>{post.title}</a></Link></h2>
        <div><span>{post.published}</span></div>
      </div>)}

      <style jsx>{`
        .post-teaser {
          margin-bottom: 2em;
        }

        .post-teaser h2 a {
          text-decoration: none;
        }

        .home-archive {
          margin: 3em;
          display: flex;
          flex-direction: row;
          justify-content: center;
        }
      `}</style>
    </Layout>
  )
}

/**
 * ページコンポーネントで使用する値を用意する
 */
export async function getStaticProps({ params }) {
  const MAX_COUNT = 5
  const posts = await readContentFiles({ fs })

  return {
    props: {
      posts: posts.slice(0, MAX_COUNT),
    }
  }
}

ここでは、投稿の詳細ページと同様に getStaticProps() を定義してページコンポーネントに渡すデータを指定しています。 pages/index.js の場合はパスは固定なので getStaticPaths() は不要です。

readContentFiles() はすべての投稿を新しいものから順に返す関数です。 未実装なので、次のとおり lib/content-loader.js に追加します。

lib/content-loader.js:

/**
 * Markdown のファイルの中身を全件パースして取得する
 */
const readContentFiles = async ({ fs }) => {
  const promisses = listContentFiles({ fs })
    .map((filename) => readContentFile({ fs, filename }))

  const contents = await Promise.all(promisses)

  return contents.sort(sortWithProp('published', true))
}

// export 対象に `readContentFiles()` を追加する
export { listContentFiles, readContentFiles, readContentFile }

sortWithProp() は各要素がオブジェクトの配列を指定されたプロパティの値でソートするためのヘルパーです。 次のとおりに定義します。

/**
 * Markdown の投稿をソートするためのヘルパー
 */
const sortWithProp = (name, reversed) => (a, b) => {
  if (reversed) {
    return a[name] < b[name] ? 1 : -1
  } else {
    return a[name] < b[name] ? -1 : 1
  }
}

これで frontmatter の published (日付)の降順でソートした投稿一覧を取得できるようになります。

これでフロントページに最新の投稿一覧が表示できるようになりました。 上で content/posts/hello.md を作ったときと同じ要領で content/posts 以下に投稿の .md ファイルを追加すると、日付の降順で投稿がフロントページに並ぶことが開発サーバー上で確認できます。

アーカイブページを作る

投稿の詳細ページとフロントページができたので、続いて、過去の投稿の一覧ページを作成しましょう。

一覧ページは /archive/ 以下にかんたんなページネーション(ページャ)がある形で作成します。 まずは詳細ページと同じようにファイル名に [] が付いたファイルを pages/ 以下に作成します。

pages/archive/[page].js:

import fs from "fs"

import Link from "next/link"

import Layout from "../../components/Layout"
import Pager from "../../components/Pager"
import { listContentFiles, readContentFiles } from "../../lib/content-loader"

const COUNT_PER_PAGE = 10

export default function Archive(props) {
  const { posts, page, total, perPage } = props
  return (
    <Layout title="アーカイブ">
      {posts.map((post) => <div
        key={post.slug}
        className="post-teaser"
      >
        <h2><Link href="/posts/[id]" as={`/posts/${post.slug}`}><a>{post.title}</a></Link></h2>
        <div><span>{post.published}</span></div>
      </div>)}

      <Pager
        page={page} total={total} perPage={perPage}
        href="/archive/[page]"
        asCallback={(page) => `/archive/${page}`}
      />

      <style jsx>{`
        .post-teaser {
          margin-bottom: 2em;
        }

        .post-teaser h2 a {
          text-decoration: none;
        }
      `}</style>
    </Layout>
  )
}

/**
 * ページコンポーネントで使用する値を用意する
 */
export async function getStaticProps({ params }) {
  const page = parseInt(params.page, 10)
  const end = COUNT_PER_PAGE * page
  const start = end - COUNT_PER_PAGE
  const posts = await readContentFiles({ fs })

  return {
    props: {
      posts: posts.slice(start, end),
      page,
      total: posts.length,
      perPage: COUNT_PER_PAGE,
    }
  }
}

/**
 * 有効な URL パラメータを全件返す
 */
export async function getStaticPaths() {
  const posts = await listContentFiles({ fs })
  const pages = range(Math.ceil(posts.length / COUNT_PER_PAGE))
  const paths = pages.map((page) => ({
    params: { page: `${page}` }
  }))

  return { paths: paths, fallback: false }
}

/**
 * ユーティリティ: 1 から指定された整数までを格納した Array を返す
 */
function range(stop) {
  return Array.from({ length: stop }, (_, i) => i + 1)
}

ここでは、各ページに最大 10 件の投稿を表示しています。 最新のもの 10 件を /archive/1 に、その次の 10 件を /archive/2 に、という感じでページを分けています。

また、ページネーションの処理が少しややこしくなりそう & またどこかで(たとえばタグ別の投稿一覧ページを作成するとき等に)再利用できそうなので、コンポーネントに切り出して Pager として import しています。

ファイル components/Pager.js を作成して次の内容で保存します。

components/Pager.js:

import Link from "next/link"

const Pager = (props) => {
  const { total, page, perPage, href, asCallback } = props

  const prevPage = page > 1 ? page - 1 : null
  let nextPage = null
  if (page < Math.ceil(total / perPage)) {
    nextPage = page + 1
  }

  return (
    <div className="pager">
      <span className="pager-item">
        {prevPage ? (
          <Link href={href} as={asCallback(prevPage)}>
            <a>{prevPage}</a>
          </Link>
        ) : ``}
      </span>
      <span className="pager-item">{page}</span>
      <span className="pager-item">
        {nextPage ? (
          <Link href={href} as={asCallback(nextPage)}>
            <a>{nextPage}</a>
          </Link>
        ) : ``}
      </span>

      <style jsx>{`
        .pager {
          display: flex;
          flex-direction: row;
          justify-content: center;
          flew-wrap: nowrap;
        }

        .pager-item {
          margin: 0 1em;
        }
      `}</style>
    </div>
  )
}

export default Pager

これでページネーション付きの過去の記事一覧ページが作成できました。 ブラウザで開発サーバーのパス /archive/1 にアクセスするとページが正しく表示されることが確認できます。

ただしこれだけだとブログの訪問者がこれらのページにたどり着けないので、フロントページにリンクを追加しておきましょう。 page/index.js の中身を次のとおりに変更します。

pages/index.js:

import fs from "fs"

import Link from "next/link"

import Layout from "../components/Layout"
import { readContentFiles } from "../lib/content-loader"

export default function Home(props) {
  const { posts, hasArchive } = props
  return (
    <Layout title="">
      {posts.map((post) => <div
        key={post.slug}
        className="post-teaser"
      >
        <h2><Link href="/posts/[id]" as={`/posts/${post.slug}`}><a>{post.title}</a></Link></h2>
        <div><span>{post.published}</span></div>
      </div>)}

      {hasArchive ? (
        <div className="home-archive">
          <Link href="/archive/[page]" as="/archive/1"><a>アーカイブ</a></Link>
        </div>
      ) : ``}

      <style jsx>{`
        .post-teaser {
          margin-bottom: 2em;
        }

        .post-teaser h2 a {
          text-decoration: none;
        }

        .home-archive {
          margin: 3em;
          display: flex;
          flex-direction: row;
          justify-content: center;
        }
      `}</style>
    </Layout>
  )
}

/**
 * ページコンポーネントで使用する値を用意する
 */
export async function getStaticProps({ params }) {
  const MAX_COUNT = 5
  const posts = await readContentFiles({ fs })
  const hasArchive = posts.length > MAX_COUNT

  return {
    props: {
      posts: posts.slice(0, MAX_COUNT),
      hasArchive,
    }
  }
}

ここでは、投稿の数が 5 件を越えた場合のみ投稿一覧ページの 1 ページ目へのリンクを表示するようにしています。

かんたんではありますが、以上でブログの主要なページが作成できました。

同じ要領で、投稿の frontmatter に tags といった名前のアイテムを付けてタグ別の投稿一覧ページを作ったりするとさらに本格的なブログになるかと思います。 アーカイブページと同じ考え方でできるので、興味のある方はやってみてください。

参考:

4. デプロイの準備をする

シンプルなブログですがこれでひとまずできたので、ウェブに公開する前の最後のステップとして、 <html>lang 属性を lang="ja" に設定して Google Fonts と Google Analytics を追加しましょう。

<html>lang 属性を lang="ja" に設定する

<html> の属性を変更するには、特殊な pages/_document.js ファイルを作成する必要があります。 pages/_document.js ファイルを作成して次の内容で保存します。

pages/_document.js:

import Document, { Html, Head, Main, NextScript } from 'next/document'

class MyDocument extends Document {
  render() {
    return (
      <Html lang="ja">
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

export default MyDocument

ファイルを保存したら、開発サーバーでページを開き直してソースを見ると、 <html lang="ja"> となっていることが確認できます。

このように、 Next.js が提供するレスポンスの HTML 全体のテンプレートを上書きしたい場合はこの pages/_document.js を作成して対応します。

尚、 Head Main NextScript は Next.js が正しく動作するために必要なので必ず含めておく必要があります。

Google Fonts を追加する

Google Fonts の追加も <html lang="ja"> と同様に pages/_document.js を使って行えます。 pages/_document.js を次のとおりに書き換えます。

pages/_document.js:

import Document, { Html, Head, Main, NextScript } from 'next/document'

class MyDocument extends Document {
  render() {
    return (
      <Html lang="ja">
        <Head>
          { /*
             * Google Fonts を利用する
             *
             * - Montserrat
             * - Noto Sans JP
             */ }
          <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@800&family=Noto+Sans+JP:wght@400;700&display=swap" rel="stylesheet" />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

export default MyDocument

先ほどは <Head /> という記述でしたが、今回は Google Fonts 利用のための <link> タグを含めたいので、開きタグと閉じタグを分けて <Head>...</Head> と書く必要があります。

開発サーバーのページを開き直せば、 Google Fonts のフォントが効いていることが確認できます。

Google Analytics を追加する

同様に Google Analytics のトラッキングコードを追加します。 今回は gtag.js を使う方向で説明します。

Google Analytics の利用には少し手間がかかります。 具体的には pages/_document.jspages/_app.js の 2 つのファイルをいじる必要があります。

まず下準備として lib/gtag.js ファイルを作成します。

lib/gtag.js:

// See: https://github.com/zeit/next.js/blob/v9.4.4/examples/with-google-analytics/lib/gtag.js
const GA_TRACKING_ID = process.env.GA_TRACKING_ID

// https://developers.google.com/analytics/devguides/collection/gtagjs/pages
const pageview = (url) => {
  window.gtag('config', GA_TRACKING_ID, {
    page_path: url,
  })
}

// https://developers.google.com/analytics/devguides/collection/gtagjs/events
const event = ({ action, category, label, value }) => {
  window.gtag('event', action, {
    event_category: category,
    event_label: label,
    value: value,
  })
}

export { GA_TRACKING_ID, pageview, event }

トラッキング ID はコード内に直接書くこともできますが、今回は環境変数から渡す形にしたいので、ここでは process.env.GA_TRACKING_ID から値を取得するようにしています。

このヘルパー関数を pages/_document.jspages/_app.js で利用します。

pages/_document.js

import Document, { Html, Head, Main, NextScript } from 'next/document'

import { GA_TRACKING_ID } from '../lib/gtag'

class MyDocument extends Document {
  render() {
    return (
      <Html lang="ja">
        <Head>
          { /*
             * Google Fonts を利用する
             *
             * - Montserrat
             * - Noto Sans JP
             */ }
          <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@800&family=Noto+Sans+JP:wght@400;700&display=swap" rel="stylesheet" />

          { /* gtag / Google Analytics を利用する */}
          {
            GA_TRACKING_ID && <script
              async
              src={`https://www.googletagmanager.com/gtag/js?id=${GA_TRACKING_ID}`}
            />
          }
          {
            GA_TRACKING_ID && <script
              dangerouslySetInnerHTML={{
                __html: `
                  window.dataLayer = window.dataLayer || [];
                  function gtag(){dataLayer.push(arguments);}
                  gtag('js', new Date());

                  gtag('config', '${GA_TRACKING_ID}', {
                    page_path: window.location.pathname,
                  });
                `,
              }}
            />
          }
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

export default MyDocument

pages/_app.js:

import Router from 'next/router'

import { GA_TRACKING_ID, pageview } from '../lib/gtag'

if (GA_TRACKING_ID) {
  Router.events.on('routeChangeComplete', url => pageview(url))
}

// This default export is required in a new `pages/_app.js` file.
export default function MyApp({ Component, pageProps }) {
  return <Component {...pageProps} />
}

環境変数の渡し方ですが、開発環境ではプロジェクトルートに .env.local ファイルを作成してその中に次のように記述すれば Next.js が自動的に読み込んでくれます。

.env.local:

GA_TRACKING_ID=XXX

ただし .env.local は開発サーバーを走らせる next dev だけでなく next buildnext export を実行したときにも読み込まれるので注意が必要です。

ビルド環境での環境変数の渡し方は、各種ビルドサービスが提供する方法を使うとよいと思います。

開発環境で .env.local を作成あるいは変更したときには開発サーバーを再起動する必要があります。

尚、環境変数を使うときには、ビルド時に Node からだけ参照したい場合と、クライアントサイドの JavaScript からも参照したい場合とがあります。 変数名が NEXT_PLUBIC_ で始まる環境変数は Node 環境とクライアントサイド JavaScript の両方で利用でき、その他の環境変数は Node 環境でのみ利用できるようになっているので、適宜使い分けるようにしましょう。

NEXT_PUBLIC_ANALYTICS_ID=abcdefghijk

実際に環境変数を使う場合は、事故を未然に防ぐために公式ドキュメントを読んで挙動を正しく理解した上で使うことをおすすめします:

これでひととおりの準備が整ったので最後にデプロイしてウェブで公開します。

5. デプロイ

ここにも多くの選択肢がありますが、今回は GitHub と Netlify を使ってサイトを公開することにします。 GitHub はコードホスティングだけに使用し、ビルドと公開には Netlify を使います。 複雑な操作は必要なくて、大体のステップはクリックでポチポチするだけで済みます。

まずはここまで作成したコードとを Git で commit し GitHub に push します。 投稿コンテンツもコードとあわせて Git に commit します。 続いて Netlify でデプロイしますが、 Netlify のアカウントを持っていない場合はまずサインアップします。

Netlify にログインしたら「 New site from Git 」のボタンをクリックしてサイトの追加を行います。

この後の流れはおおよそ以下のとおりです。 スクリーンショットがあるとわかりやすいですが、 UI は時間が経つとすぐに変わってしまうところでもあるので、ここでは大まかな流れを説明するだけにとどめます。

  1. Git provider として「 GitHub 」を選択します。
  2. GitHub から Netlify を app として認証するかどうかのダイアログが出るので許可します。
  3. 対象のリポジトリを選択します。
  4. 細かな設定をします。

ある程度英語が読める方であれば、迷うところはあまり無いかと思います。

ひととおり完了できたら、 Google Analytics のトラッキング ID を環境変数で指定するために設定ページを開きます。 記事執筆時点では Settings → Build & deploy → Environment → Environment variables で設定できます。

環境変数を設定したら、最後にデプロイ処理をトリガーすれば、ビルド処理が走ってサイトが自動的に公開されます。

参考:

以上で終了です。

以後、投稿を追加したいときには、投稿の .md ファイルを追加して commit → push すれば、 Netlify 側でビルド処理が自動的に走りサイトが更新されます。

おわりに

ということで、 Next.js と GitHub と Netlify を使って Jamstack なブログを作る方法について紹介しました。

本格的なブログとして完成させるのは他にもさまざまな作業が必要です:

  • 独自ドメインでの公開
  • タグ別の記事一覧ページの作成
  • ウィジェットの作成
  • フィードの生成
  • サイトマップの生成
  • メタタグの追加
  • OGP タグの追加
  • favicon の差し替え
  • PWA 対応

興味のあるものがあればチャレンジしてみてください。

参考

追記 2020/09/10

Next.js が使われたコーポレートサイトの事例をまとめてみました。 別ブログですが興味のある方はご覧になってみてください。

追記 2021/01/19

コード内のタイポのご指摘をいただきコードを修正しました。 お知らせくださった方、ありがとうございました。


アバター
後藤隼人 ( ごとうはやと )

Python や PHP を使ってソフトウェア開発やウェブ制作をしています。詳しくはこちら