zenpachi blog

Next.js + TypeScript + VercelでJamstackなブログを作る その1 - 環境構築/記事の作成・表示

このエントリーをはてなブックマークに追加

Next.js 9.3よりSSG(Static Site Generation)機能が充実したため試してみたいと思っていたのですが、どうせならなにかを作るかということでこのブログを作り直してみることにしました。

Blog - Next.js 9.3 | Next.js

いままでこのブログははてなブログを使用していましたがNext.jsのSSGを使用してJamstackな構成で作り直しています。
結論としては、とりあえず基本的な機能のみを利用してミニマムな機能に絞って作ってみましたがそこそこ使いやすく、それほど凝ったことをしなければ実務でも十分対応できそうです。

以下は開発の備忘録。
説明の都合上実際のファイル構成・コンポーネント粒度やコードの細部は異なる部分もありますがどなたかの参考になれば。

  • node: 14.7.0
  • npm: 6.14.7
  • next: 9.5.3
  • typescript: 4.0.3

環境を整える

はじめは環境づくりです。
公式ドキュメントの tutorialがStep by Stepで非常に親切になっているのでこちらに沿っていけば環境づくりで詰まることはなさそうです。

Create a Next.js App | Learn Next.js

Reactの create-react-appと同様にcreate-next-appという雛形を自動で作ってくれる仕組みがあるためこちらを使います。

$ npx create-next-app . --use-npm

--use-npmオプションをつけるとpackage-lock.jsonを、つけなければyarn.lockを作成します。Next.js的にはyarn推しのようですね。今回はnpmをつけたいので--use-npmをつけています。

tutorialのように--exampleオプションを使ってhttps://github.com/vercel/next.js/tree/canary/examplesから開発したいアプリの構成に近いサンプルを引っ張ってくることもできますが、今回は学習のため出来るだけシンプルな状態から始めたいので--exampleオプションはつけていません。

また-with-typescriptオプションを使えばTypeScriptを含めたプロジェクトの雛形を作成してくれますが、こちらも今回は自前で設定したかったため使用していません。

作成されたpackage.jsをみると以下のようになっています。

{
  "name": "YOUR APP NAME",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "9.5.3",
    "react": "16.13.1",
    "react-dom": "16.13.1"
  }
}

この時点でNext.jsは動くようになっているので開発サーバを立ち上げてみます。

$ npm run dev

これで開発サーバが立ち上がるのでhttp://localhost:3000にアクセスすればトップページが表示されます。

開発サーバを起動

必要ないファイルを削除する

せっかく用意してくれた雛形ですが、まっさらな状態でブログを作っていきたいので必要のないファイルは削除していきます。

各ページのファイルが/pagesに格納されていますが他のアプリ開発でもよく見るように開発ファイルは/src配下にしたいので/pagesディレクトリは削除してしまいます。
同様に/styles/public/vercel.svgもとりあえずは必要なさそうなので削除します。

TypeScriptの導入

通常通りTypeScriptの設定をしていきます。

$ npm i --save-dev typescript @types/node @types/react @types/react-dom

tsconfig.jsonファイルを作成してから再度開発サーバを立ち上げると自動的にtsconfig.jsonファイルに設定が追加されます。

$ touch tsconfig.json
$ npm run dev
{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "baseUrl": "./",
        "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx"
  ],
  "exclude": [
    "node_modules"
  ]
}

基本的にそのまま使用していますが/srcに対して@でアクセスできるようにエイリアスを設定するのと、"target": "es2020", "strict": trueのみ設定を変更しています。

ESLint, Prettierの導入

この辺りはプロジェクトの状況や個人の好み次第だと思いますが、もはや導入することがデファクトスタンダードと言っていいでしょう。たぶん。

$ npm i --save-dev eslint prettier eslint-config-prettier \
eslint-plugin-prettier eslint-plugin-react eslint-plugin-react-hooks \
@typescript-eslint/eslint-plugin @typescript-eslint/parser
{
  "semi": false,
  "trailingComma": "all",
  "singleQuote": true,
  "printWidth": 100,
  "tabWidth": 2,
  "arrowParens": "avoid"
}
{
  "root": true,
  "plugins": ["react"],
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:prettier/recommended",
    "prettier/@typescript-eslint"
  ],

  "parser": "@typescript-eslint/parser",
  "env": {
    "es2020": true
  },
  "parserOptions": {
    "project": ["./tsconfig.json"]
  },
  "rules": {
    "react/react-in-jsx-scope": "off",
    "react/prop-types": "off",
    "@typescript-eslint/explicit-module-boundary-types": "off"
  }
}

ダミーページを作成

/pagesを削除したので代わりのダミーページを作成します。
/src/pagesファイル配下に.tsxファイルを配置すれば自動的にページとして認識されます。
/src/pages配下のパスがそのままページのURLパスとなります。

import { NextPage } from 'next'

const Index: NextPage = () => {
  return <h1>Awesome BLOG</h1>
}

export default Index

これでlocalhost:3000にアクセスすればダミーのページが表示されます。

CSSとCustom App

Next.jsはデフォルトでCSS Modulesをサポートしています。
style-componentsやEmotionなど人によって好みはあると思いますが、今回は最初からサポートされているCSS Modulesを使っていきます。

css-modules/css-modules: Documentation about css-modules

import { NextPage } from 'next'
import styles from './index.module.css'

const Index: NextPage = () => {
  return <h1 className={styles.heading}>Awesome BLOG</h1>
}

export default Index
.heading {
  padding: 16px;
  color: steelblue;
  font-size: 24px;
  font-weight: bold;
}

CSSとCustom App

なんの設定もなくCSS Moduleが有効になっています。便利ですね。
ページ(コンポーネント)毎のstyleはこれでいいですが、サイト全体で読み込みたいglobalなCSSやreset系CSSなどを全てのページでimportするのは面倒です。
そんな時は Custom App を使います。
Next.jsは、Appコンポーネントを使用してページを初期化していますが Custom Appを使用するとAppコンポーネントをオーバーライドすることができます。

Advanced Features: Custom App | Next.js

使い方は簡単で、/src/pages直下に_app.tsxを作成するだけで、ここでglobalなCSSやreset CSSなどをimportすることで全ページに反映させることができます。

今回はreset CSSとしてminireset.cssを使用します。

$ npm i --save minireset.css
import { AppProps } from 'next/app'

import 'minireset.css'

export default function App({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />
}

記事ページを作る

Next.js 9.3 から追加されたSSG向けの機能を使ってブログの記事ページを作成していきます。
主にgetStaticPropsgetStaticPathsというAPIを使っていきます。

この2つは公式によると、

  • getStaticProps (Static Generation): Fetch data at build-time.
  • getStaticPaths (Static Generation): Specify dynamic routes to prerender based on data.

とのことで、ビルド時にデータを取得したり、データに基づいてダイナミックルートを指定したりする場合に使います。 つまり、これらはビルド時にしか実行されず、そこで取得したデータに基づいてページのURLや内容が決定します。

まずは記事ページを作成します。
記事ページのURLは/posts/${id}という形を想定してますが、その場合は/src/pages/posts/配下に/[id]/index.tsxもしくは[id].tsxというファイルを配置します。
今回は/[id]/index.tsx形式を使用します。

import { NextPage } from 'next'

type Props = {
  title: string
  content: string
}

const Post: NextPage<Props> = ({ title, content }) => {
  return (
    <>
      <h1>{title}</h1>
      <p>{content}</p>
    </>
  )
}

export default Post

titleとcontentをpropsで受け取って表示するだけのシンプルなページです。
ここにgetStaticPropsgetStaticPathsを使ってページのURLと渡すpropsを決めていきます。

まずはgetStaticPathsを使ってレンダリングするパスのリストを定義します。
公式ドキュメント にある通りpathsfallbackkeyを含むオブジェクトをreturnします。

export async function getStaticPaths() {
  return {
    paths: [
      { params: { ... } } // See the "paths" section below
    ],
    fallback: true or false // See the "fallback" section below
  }
}

paths.params内のkey/valueによって動的ルートのパスは決定します。
作成中のページは/posts/[id]/index.tsxという動的ルートを使用していますので、{ params: { id: '1' } }とすれば/posts/1/というページがレンダリングされることになります。

fallbackfalseにしている場合、pathsに含まれないURLへのアクセスに対しては404となります。
trueにすることで、pathsに含まれないURLへのアクセスに対して動的にページを生成し表示することができます。(各URLごとに最初のリクエストでページを生成して次回からはそちらを使用する)
当然ながら事前に静的なHTMLとしてページを生成しておくnext exportは使用できなくなります。今のところ(2020/10/11時点)ではこのブログはVercelにホスティングしており、next exportは使用していないのでfallbackを使うこともできるのですが、将来的に他の静的ホスティングサービスに移行する可能性もありますし、動的にページを生成する必要もないため今回はfalseとします。

ひとまずは/posts/1//posts/2/というページをレンダリングすることにしてみましょう。

import { GetStaticPaths } from 'next'

...

export const getStaticPaths: GetStaticPaths = async () => {
  return {
    paths: [
      { params: { id: '1' } },
      { params: { id: '2' } },
    ],
    fallback: false
  }
}

次はgetStaticPropsです。
getStaticPropsはページのレンダリングに使うpropsを返します。

export async function getStaticProps(context) {
  return {
    props: {}, // will be passed to the page component as props
  }
}

contextにはparamskeyを含み、今回のような/posts/[id]/index.tsxというページであれば{ id: ... }という値を持っています。

またgetStaticPropspropskeyを含むオブジェクトをreturnする必要があります。
これはページコンポーネントに渡されるpropsになります。

ほかにもcontextに含まれるkeyやreturnするオブジェクトに含めることのできるkeyはありますが今回は使わないので省略します。

詳細は公式ドキュメントを確認してください。

ページにレンダリングする記事のデータはまだないのでひとまずダミーデータをつくり、ページのidによって出し分けるようにします。

import { GetStaticPaths, GetStaticProps } from 'next'

...

const dummyData = {
  1: {
    title: 'id1のtitle',
    content: 'id1のcontext',
  },
  2: {
    title: 'id2のtitle',
    content: 'id2のcontext',
  },
}

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const props: Props = dummyData[params!.id as '1' | '2']
  return { props }
}

dummyデータということもあって型はやや無理やりですがこれでhttp://localhost:3000/posts/1http://localhost:3000/posts/2 にアクセスするとそれぞれの記事ページが表示されます。

記事データの取得と表示

次はダミーデータではなく実際の記事データを取得し表示する部分です。

記事の管理にはContentffulやmicroCMSといったHeadless CMSつかう方法もありますが、今回はMarkdown形式のファイルをリポジトリ内に直接置いていく方法としました。そのうち変えるかもしれませんが、個人ブログ程度であればしばらくはこれで十分かと思います。

Markdownファイルを用意する

記事のファイルを作成します。ファイルは/posts配下に置いておきます。
サンプル記事のテキストは青空文庫より拝借しました。

---
title: '坊っちゃん'
---

親譲おやゆずりの無鉄砲むてっぽうで小供の時から損ばかりしている。小学校に居る時分学校の二階から飛び降りて一週間ほど腰こしを抜ぬかした事がある。なぜそんな無闇むやみをしたと聞く人があるかも知れぬ。別段深い理由でもない。新築の二階から首を出していたら、同級生の一人が冗談じょうだんに、いくら威張いばっても、そこから飛び降りる事は出来まい。弱虫やーい。と囃はやしたからである。小使こづかいに負ぶさって帰って来た時、おやじが大きな眼めをして二階ぐらいから飛び降りて腰を抜かす奴やつがあるかと云いったから、この次は抜かさずに飛んで見せますと答えた。  

親類のものから西洋製のナイフを貰もらって奇麗きれいな刃はを日に翳かざして、友達ともだちに見せていたら、一人が光る事は光るが切れそうもないと云った。切れぬ事があるか、何でも切ってみせると受け合った。そんなら君の指を切ってみろと注文したから、何だ指ぐらいこの通りだと右の手の親指の甲こうをはすに切り込こんだ。幸さいわいナイフが小さいのと、親指の骨が堅かたかったので、今だに親指は手に付いている。しかし創痕きずあとは死ぬまで消えぬ。  

庭を東へ二十歩に行き尽つくすと、南上がりにいささかばかりの菜園があって、真中まんなかに栗くりの木が一本立っている。これは命より大事な栗だ。実の熟する時分は起き抜けに背戸せどを出て落ちた奴を拾ってきて、学校で食う。菜園の西側が山城屋やましろやという質屋の庭続きで、この質屋に勘太郎かんたろうという十三四の倅せがれが居た。勘太郎は無論弱虫である。弱虫の癖くせに四つ目垣を乗りこえて、栗を盗ぬすみにくる。ある日の夕方折戸おりどの蔭かげに隠かくれて、とうとう勘太郎を捕つらまえてやった。その時勘太郎は逃にげ路みちを失って、一生懸命いっしょうけんめいに飛びかかってきた。向むこうは二つばかり年上である。弱虫だが力は強い。鉢はちの開いた頭を、こっちの胸へ宛あててぐいぐい押おした拍子ひょうしに、勘太郎の頭がすべって、おれの袷あわせの袖そでの中にはいった。邪魔じゃまになって手が使えぬから、無暗に手を振ふったら、袖の中にある勘太郎の頭が、右左へぐらぐら靡なびいた。しまいに苦しがって袖の中から、おれの二の腕うでへ食い付いた。痛かったから勘太郎を垣根へ押しつけておいて、足搦あしがらをかけて向うへ倒たおしてやった。山城屋の地面は菜園より六尺がた低い。勘太郎は四つ目垣を半分崩くずして、自分の領分へ真逆様まっさかさまに落ちて、ぐうと云った。勘太郎が落ちるときに、おれの袷の片袖がもげて、急に手が自由になった。その晩母が山城屋に詫わびに行ったついでに袷の片袖も取り返して来た。
# /posts/sample-post-02.md
---
title: '吾輩は猫である'
---

吾輩わがはいは猫である。名前はまだ無い。  

どこで生れたかとんと見当けんとうがつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。しかもあとで聞くとそれは書生という人間中で一番獰悪どうあくな種族であったそうだ。この書生というのは時々我々を捕つかまえて煮にて食うという話である。しかしその当時は何という考もなかったから別段恐しいとも思わなかった。ただ彼の掌てのひらに載せられてスーと持ち上げられた時何だかフワフワした感じがあったばかりである。掌の上で少し落ちついて書生の顔を見たのがいわゆる人間というものの見始みはじめであろう。この時妙なものだと思った感じが今でも残っている。第一毛をもって装飾されべきはずの顔がつるつるしてまるで薬缶やかんだ。その後ご猫にもだいぶ逢あったがこんな片輪かたわには一度も出会でくわした事がない。のみならず顔の真中があまりに突起している。そうしてその穴の中から時々ぷうぷうと煙けむりを吹く。どうも咽むせぽくて実に弱った。これが人間の飲む煙草たばこというものである事はようやくこの頃知った。  

この書生の掌の裏うちでしばらくはよい心持に坐っておったが、しばらくすると非常な速力で運転し始めた。書生が動くのか自分だけが動くのか分らないが無暗むやみに眼が廻る。胸が悪くなる。到底とうてい助からないと思っていると、どさりと音がして眼から火が出た。それまでは記憶しているがあとは何の事やらいくら考え出そうとしても分らない。

Markdownファイルの冒頭にはメタデータを記述しておきます。 上記のサンプルはtitleのみですが、公開日やdescription、カテゴリなどもここに書いておくのが良いでしょう。このメタデータはgray-matterで解析することができます。

$ npm i gray-matter

また、Markdown形式のファイルをhtml形式に変換するためのモジュールもインストールしておきます。

$ npm i unified remark-rehype remark-rehype remark-stringify

記事ファイルを読み込んで表示する

次にこの記事ファイルを読み込むための処理を書きます。

import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import unified from 'unified'
import markdown from 'remark-parse'
import remark2rehype from 'remark-rehype'
import stringify from 'rehype-stringify'

const POSTS_DIRECTORIES = path.join(process.cwd(), 'posts')

type MatterResult = {
  content: string
  data: {
    title: string
  }
}

export type Post = {
  content: string
  title: string
}

// 全てのpostのid一覧を取得
export function getAllPostIds() {
  const fileNames = fs.readdirSync(POSTS_DIRECTORIES)
  return fileNames.map(fileName => {
    return fileName.replace(/\.md$/, '')
  })
}

// idからpostを取得する
export async function getPostData(id: string): Promise<Post> {
  const fullPath = path.join(POSTS_DIRECTORIES, `${id}.md`)
  const fileContent = fs.readFileSync(fullPath, 'utf8')
  const matterResult = matter(fileContent)
  const matterResultData = matterResult.data as MatterResult['data']

  const processedContent = await unified()
    .use(markdown)
    .use(remark2rehype)
    .use(stringify)
    .process(matterResult.content)
  const content = processedContent.toString()

  return {
    content,
    ...matterResultData,
  }
}

getAllPostIds()getStaticPathsで使うためのid一覧を、getPostData()getStaticPropsで使うための記事データをそれぞれ取得します。またgetPostData()内ではMarkdown形式をhtml形式に変換する処理を行なっています。

これらを使うようにページ側も修正します。

import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from 'next'
import { getPostData, getAllPostIds, Post } from '@/lib/posts'

export const getStaticPaths: GetStaticPaths = async () => {
  const ids = await getAllPostIds()
  return {
    paths: ids.map(id => ({ params: { id } })),
    fallback: false,
  }
}

export const getStaticProps: GetStaticProps<Post, { id: string }> = async context => {
  const data = await getPostData((context.params || {}).id as string)
  return { props: data }
}

const PostPage = ({ title, content }: InferGetStaticPropsType<typeof getStaticProps>) => {
  return (
    <>
      <h1>{title}</h1>
      <div dangerouslySetInnerHTML={{ __html: content }} />
    </>
  )
}

export default PostPage

はじめにgetStaticPathsが一度だけ実行されレンダリングの必要なパスを決定します。
その次にgetStaticPropsがレンダリングするページの数だけ実行され、それぞれのページのidから記事の内容を取得し表示します。

http://localhost:3000/posts/1にアクセスすると記事の内容が表示されます。

記事ファイル名とページのURLを変えたい場合

先ほどは記事ファイルのファイル名をidとして扱ったためファイル名がそのままURLとなりましたが、何らかの理由でファイル名とURLを別にしたい場合を考えます。

まずは記事のメタデータにURLとして使いたいidを記述します。

---
id: 'bocchan'
title: '坊っちゃん'
---

...

このid: 'bocchan'をURLとして使えるようにしていきます。

上手くいかない方法

getStaticPathsでURLとして使うidだけでなくファイル名もreturnすればgetStaticProps内でファイル名を取り出せると思って実装したところ上手くいきませんでした。

...

type MatterResult = {
  content: string
  data: {
    id: string
    title: string
  }
}

export type Post = {
  content: string
  id: string
  title: string
}

// 全てのpostのidとfileNameの一覧を取得
export function getAllPostIdAndFileNames() {
  const fileNames = fs.readdirSync(POSTS_DIRECTORIES)
  return fileNames.map(fileName => {
    const fullPath = path.join(POSTS_DIRECTORIES, fileName)
    const fileContent = fs.readFileSync(fullPath, 'utf8')
    const matterResult = matter(fileContent)
    const matterResultData = matterResult.data as MatterResult['data']
    return {
      id: matterResultData.id,
      fileName: fileName.replace(/\.md$/, ''),
    }
  })
}

// idからpostを取得する
export async function getPostData(fileName: string): Promise<Post> {
  const fullPath = path.join(POSTS_DIRECTORIES, `${fileName}.md`)
  
  ...
}
...

import { getPostData, getAllPostIdAndFileNames, Post } from '@/lib/posts'

export const getStaticPaths: GetStaticPaths = async () => {
  const idAndFileNames = await getAllPostIdAndFileNames()
  return {
    paths: idAndFileNames.map(idAndFileName => ({
      params: idAndFileName,
    })),
    fallback: false,
  }
}

export const getStaticProps: GetStaticProps<Post, { id: string, fileName: string }> = async context => {
  const data = await getPostData((context.params || {}).fileName as string)
  return { props: data }
}

...

記事ファイル名とページのURLを変えたい場合

getStaticPropsが受け取るcontext.paramsは動的ルートのパスに含まれるkey(今回はid)しか持たないようで、getStaticPathsでreturnに含めても fileNameは受け取ることができないようです。

上手くいった方法

はじめにidとfileNameのMapを作っておき、getPostDataではidからfileNameをひけるようにすることでidとfileNameを分けることができます。

...

const POSTS_DIRECTORIES = path.join(process.cwd(), 'posts')
const ID_FILENAME_MAP = (() => {
  const map = new Map()
  const fileNames = fs.readdirSync(POSTS_DIRECTORIES)
  fileNames.forEach(fileName => {
    const fullPath = path.join(POSTS_DIRECTORIES, fileName)
    const fileContent = fs.readFileSync(fullPath, 'utf8')
    const matterResult = matter(fileContent)
    const matterResultData = matterResult.data as MatterResult['data']
    map.set(matterResultData.id, fileName.replace(/\.md$/, ''))
  })
  return map
})()

type MatterResult = {
  content: string
  data: {
    id: string
    title: string
  }
}

export type Post = {
  content: string
  id: string
  title: string
}

// 全てのpostのid一覧を取得
export function getAllPostIds() {
  return Array.from(ID_FILENAME_MAP.keys())
}

// idからpostを取得する
export async function getPostData(id: string): Promise<Post> {
  const fullPath = path.join(POSTS_DIRECTORIES, `${ID_FILENAME_MAP.get(id)}.md`)
  
  ...
}

これでhttp://localhost:3000/posts/bocchanにアクセスして記事の内容が表示されることが確認できました。
ここはもっとシンプルに実現する方法がある気もしますが、あまり情報を見つけられなかったのでとりあえず上記の方法を採用しました。


思っていたより長くなったためいったんここまで。
記事一覧ページの作成、metaデータの設定、シンタックスハイライト・TeXの表示、RSS・sitemapの設定、Deployあたりなどを次の記事として書こうと思います。

追記

続きを書きました。
Next.js + TypeScript + VercelでJamstackなブログを作る その2 - meta/syntax highlight/RSS/Deploy

このエントリーをはてなブックマークに追加