hamakou108 blog

ブログを Next.js に移行した

Cover Image for ブログを Next.js に移行した

つい先日、このブログを Gatsby から Next.js に移行した。

このブログを開発し始めたのは2019年末のことで、実は最初は Next.js を採用しようと目論んでいた。 しかし React や Vue での開発経験がほとんどなかった当時の自分にはハードルが高く、より簡単に始められそうな Gatsby を選択したのだった。

そこから月日は流れて2021年9月のシルバーウィーク、ある程度のフロントエンドの経験を積んだ自負もあったのに加え、知らぬ間に Next.js が SSG サポートしていたこともあり、もう一度チャレンジしてみた。

Next.js は公式から Gatsby から Next.js への移行ガイドが提供されている。 しかし今回は移行に加えて

  • TypeScript の導入
  • Tailwind CSS の導入
  • ページ階層の整理

など、併せて実施したい作業も多かった。そのため Create Next App から始めて一から作り直すことにした。

プロジェクトの雛形の作成

始めに Create Next App でプロジェクトの雛形を作成する [^1] 。 今回は以下のテンプレートを利用した。

next.js/examples/blog-starter-typescript at master · vercel/next.js

tmp 以下に生成したファイルを現ディレクトリに移動し、 tmp は削除する。

yarn create next-app --example blog-starter-typescript tmp

Linter と Formatter の設定

今回は以下のツールを導入した。

まず必要なパッケージを追加する。

# ESLint 関連のパッケージ
yarn add --dev eslint eslint-config-next @typescript-eslint/eslint-plugin @typescript-eslint/parser
# Stylelint 関連のパッケージ
yarn add --dev stylelint stylelint-scss stylelint-config-recommended-scss
# Prettier 関連のパッケージ
yarn add --dev --exact prettier
yarn add --dev eslint-config-prettier stylelint-config-prettier

ESLint の設定は以下の通り。 .eslintrc.json では ESLint と Prettier のルールの競合を防ぐための設定 ("prettier") を継承している [^2] 。

  • .eslintrc.json
{
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "extends": [
    "next/core-web-vitals",
    "plugin:@typescript-eslint/recommended",
    "prettier"
  ]
}
  • .eslintignore
**/node_modules/*
**/.next/*

Stylelint の設定は以下の通り。 Tailwind CSS のシンタックスがエラーになるのを避けるために "ignoreAtRules" に幾つかルールを追加している [^3] 。

  • .stylelintrc.json
{
  "extends": [
    "stylelint-config-recommended-scss",
    "stylelint-config-prettier"
  ],
  "rules": {
    "scss/at-rule-no-unknown": [
      true,
      {
        "ignoreAtRules": [
          "tailwind",
          "apply",
          "variants",
          "responsive",
          "screen"
        ]
      }
    ]
  }
}

Prettier の設定は以下の通り。

  • .prettierrc.json
{
  "semi": false,
  "singleQuote": true
}
  • .prettierignore
node_modules
.next
yarn.lock
public

次に pre-commit hook を設定する。 README に従って以下のスクリプトを実行する。

npx mrm@2 lint-staged

スクリプト実行後に package.json に設定が追加されるので、それを上書きする [^4] 。

{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": "eslint --fix",
    "*.{css,sass,scss}": "stylelint --fix",
    "*.{js,jsx,ts,tsx,html,css,sass,scss,md}": "prettier --write"
  }
}

CSS を Sass に置き換え

Next 11.0.0 では Sass を組み込みでサポートしている。 まず sass パッケージを追加する

yarn add sass

後は拡張子が .css になっているファイルの名前を .scss に変更すれば良い。

テンプレートの修正

テンプレートのままだと不都合ないくつかの点について修正した。

Favicon の差し替え

以前は Web 上の Favicon 生成ツールで作った没個性的な画像を使っていたので、今回は自分で描いてみた。 パートナーの趣味の影響 [^5] で少し上手く描けるようになっていたペンギンを採用した。

各種 Favicon 画像ファイルの生成には RealFaviconGenerator を利用した。

Twitter カード用の meta タグを追加

すべてのページに適用する meta.tsx には以下のように Twitter カード用の meta タグを追加した。

  • components/meta.tsx
const Meta: React.FC = () => {
  const description = 'some description'
  return (
    <Head>
      <!-- ... -->

      <meta name="twitter:card" content="summary" />
      <meta name="twitter:site" content="@twitter_id" />
      <meta name="twitter:title" content="Blog Title" />
      <meta name="twitter:description" content="This is blog descriptions." />
      <meta name="twitter:image" content="/assets/cover.png" />
    </Head>
  )
}

ブログの記事詳細ページに当たる pages/posts/[slug].tsx にも追加し、記事詳細の内容で meta タグを上書きするようにした。

og:image タグの重複排除

デフォルトの og:image とページ固有の og:image の両方がレンダリングされてしまう問題があった。 key を指定して一度だけレンダリングされることを保証するようにした。

<meta property="og:image" content="/assets/cover.png" key="og:image" />

箇条書きやリンクなどのスタイルを調整

記事本文中のスタイルを微修正した。

  • components/markdown-styles.module.scss
.markdown li {
  @apply my-1 ml-8;

  ul, ol {
    @apply my-2;
  }
}

.markdown ul > li {
  @apply list-disc;
}

.markdown a {
  @apply text-blue-400 hover:underline;
}

MDX の導入とカスタムコンポーネントの追加

旧ブログでは MDX を導入して Markdown 中で React コンポーネントを呼び出せるようにしていた。 新ブログでも引き続きその需要があるので、 next-mdx-remote [^6] を利用して MDX を導入した。

まず必要なパッケージを追加する。 今回はコードブロックの syntax highlight に PrismJS を利用したかったため、併せて remark-prism を追加した。

yarn add next-mdx-remote prismjs remark-prism

記事ファイルの拡張子を .mdx に変更し、 lib/api.ts で読み込み先ファイルの拡張子の指定を変更する。

export function getPostBySlug(slug: string, fields: string[] = []): Items {
  const realSlug = slug.replace(/\.mdx$/, '')
  const fullPath = join(postsDirectory, `${realSlug}.mdx`)

  // ...
}

記事ファイルから読み込んだ文字列を HTML に変換してコンポーネントに渡している箇所があるので、それらを修正する。 以下では例として、 <YoutubeVideo> という Youtube 動画の iframe の幅をレスポンシブに調整するためのコンポーネントを MDX ファイル内に記述すると、 React コンポーネントとしてレンダリングされるように設定している。 また next-mdx-remote では文字列を一度 serialize する工程があり、そのオプションに remark-prism を指定することで PrismJS で syntax highlight できる形式にレンダリングすることができる。

  • components/youtube-video.tsx
const YoutubeVideo: React.FC<Props> = ({ url }: Props) => {
  return (
    <div className="relative w-full h-0 pb-[56.25%]">
      <iframe
        src={url}
        allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
        className="absolute top-0 left-0 w-full h-full"
      />
    </div>
  )
}
  • components/post-body.tsx
import { MDXRemote, MDXRemoteSerializeResult } from 'next-mdx-remote'
import YoutubeVideo from './youtube-video'

type Props = {
  source: MDXRemoteSerializeResult
}

const components = { YoutubeVideo }

const PostBody: React.FC<Props> = ({ source }: Props) => {
  return (
    <div className="max-w-2xl mx-auto">
      <div className={markdownStyles['markdown']}>
        <MDXRemote {...source} components={components} />
      </div>
    </div>
  )
}
  • pages/posts/[slug].tsx
import { MDXRemoteSerializeResult } from 'next-mdx-remote'
import { serialize } from 'next-mdx-remote/serialize'

type PostProps = {
  post: Items
  source: MDXRemoteSerializeResult
}

const Post: React.FC<Props> = ({ post, source, preview }: Props) => {
  // ...

  return (
    <Layout preview={preview}>
      <Container>
        <Header />
          <>
            <article className="mb-32">
              <!-- ... -->

              <PostBody source={source} />
            </article>
          </>
        )}
      </Container>
    </Layout>
  )
}

export async function getStaticProps({
  params,
}: Params): Promise<GetStaticPropsResult<PostProps>> {
  const post = getPostBySlug(params.slug, [
    // ...

    'content',
  ])
  const mdxSource = await serialize(
    post.content,
    {
      mdxOptions: {
        remarkPlugins: [
          [
            require('remark-prism')
          ]
        ]
      }
    }
  )

  return {
    props: {
      post: {
        ...post,
      },
      source: mdxSource
    },
  }
}

また PrismJS 用の CSS は pages/_app.tsx で import して、グローバル CSS として適用しておく。

import 'prismjs/themes/prism.css'

Google Tag Manager

Google Tag Manager のスクリプトの設置には react-gtm-module を利用した。

  • pages/_app.tsx
import TagManager from 'react-gtm-module'

export default function MyApp({
  Component,
  pageProps,
}: AppProps): ReactElement {
  useEffect(() => {
    TagManager.initialize({ gtmId: 'GTM-XXXXXXX' })
  }, [])
  return <Component {...pageProps} />
}

また Google Tag Manager の設定で、ページビューのトリガーでは戻るボタンでページ移動したときのイベントを補足できなかったので、 History Change をトリガーに加えて対処した [^8] 。

デプロイ

元々は Netlify でホスティングしていたが、日本国内からのアクセスなら Vercel の方が早いという話もあり、せっかくなので Vercel に鞍替えすることにした。

Vercel へのデプロイは該当の GitHub リポジトリを選択してビルド設定を追加するだけで完了する。

ネームサーバーに関しては、以前は DNS の設定上の都合で Netlify のネームサーバーを利用していたが、 Vercel の場合は特に不都合がないのでドメイン管理会社のネームサーバーに移管した [^7] 。 その後 Vercel の Domains 設定ページで対象のドメインを追加し、表示されるレコードをネームサーバーに追加する。 追加が完了して少し待つと Vercel の Domains 設定ページの表示が "Valid Configuration" に変わるので、それを確認する。 また少し待つとサイトが表示できるようになる。

まとめ

Next.js のテンプレートから始めてブログを公開するまでの手順をまとめた。

細かい部分を調べつつ、ブログも書きつつ作業した結果、最終的な作業時間は30時間を超えていた。 とは言え、こだわらなければあまり時間を掛けずに SSG な爆速ブログを開発できるので、 Next.js はオススメ。

[^1]: 途中まで Docker でローカル開発環境を作っていたが、 IDE や Vim 上で Linter を機能させるだけで消耗しそうだったので、今回は断念した。 [^2]: ESLint と Prettier の共存のための設定方法については、本記事の執筆時点では eslint-config-prettier で Prettier の不要なルールを OFF にするのが推奨されるらしい。他には @sadnessOjisan さんの記事@soarflat さんの記事を参考にした。 [^3]: 基本的には at-rule-no-unknown に例外を追加するのだが、 stylelint-config-recommended-scss を導入した場合は at-rule-no-unknown が無効化され、 scss/at-rule-no-unknown が有効になっているので注意する。 [^4]: Next 11.0.0 で ESLint が統合されたため、 next lint で lint を実行することも可能。しかし pre-commit hook の実行時にエラーとなってしまったため、今回は使用を見送っている。 [^5]: パートナーがペンギンの LINE スタンプを販売しているので、是非購入を検討してほしい。 [^6]: 他にも幾つかの導入手段があり、 Tyler Smith さんのライブラリ比較記事を参考に選出した。 [^7]: 自分はうっかり元のネームサーバーの TTL を 3600 にしたまま移管したので、通信経路によってはしばらくブログにアクセスできない状態だった。