トップ画像
Nextjs+microCMSで10秒ごとに更新かつpropsを自動補完できるページの作り方

執筆者: どあんりょ (旧:sasigume)

最終更新: 2/13/2022

microCMSで、記事タイトル・スラッグ・サムネイル・本文を取得し、NextjsでISR(Incremental Static Rendering)します。その際、エディタでPropsを全部自動補完できるようにします。

環境

  • VSCode
  • Node.js v16.14.0
  • microCMS API v1

microCMS側の仕様変更や画面デザインの変更があるかもしれないのでご注意ください。

Next.jsの準備

npx create-next-app@latest --ts


Googleのサジェストでcreate-nuxt-appばっかり出てきて困りますね。Next使いはこれぐらい検索しないので出ないんでしょう(偏見)。

{
  "name": "nextjs-microcms-practice",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "next": "12.0.10",
    "react": "17.0.2",
    "react-dom": "17.0.2"
  },
  "devDependencies": {
    "@types/node": "17.0.17",
    "@types/react": "17.0.39",
    "eslint": "8.9.0",
    "eslint-config-next": "12.0.10",
    "typescript": "4.5.5"
  }
}


▲昔と違ってeslint-config-next が入っています。今回はこれ以上lintはいじりません。

microCMSでAPIを用意


/api/v1/article というルートのエンドポイントを作りました。


ブログ的なサイトを作るので、リスト形式にします。


以下のようにフィールドを設定してください。

  • title / タイトル / テキストフィールド / 必須
  • slug / スラッグ / テキストフィールド / 必須 / ユニーク / 後述の正規表現を適用
  • thumbnail / サムネイル / 画像 / 任意
  • body / 本文 / リッチエディタ / 必須


スラッグの詳細設定

コンテンツIDをスラッグとして使うことも可能ですが、非エンジニアに触らせるのは危険すぎるので、別でユニークなスラッグフィールドを作りましょう。


ユニークの設定は「詳細設定」のところにあります。


「特定のパターンのみ入力を許可する」をONにしてください。パターンは^[a-z0-9]+(?:-[a-z0-9]+)*$でフラッグはなしです。

  • article-1 はOKですが、
  • -artielce-1 みたいな先頭のハイフンはNGで、
  • article--1 みたいな2回スラッグもNG、
  • article-1- みたいな末尾のスラッグもNGになります。


お前こんなのどこで見つけたんだよって感じですが、


https://ihateregex.io/expr/url-slug/

i Hate Regex(正規表現なんて大っ嫌い)という神サイトがあってですね、ここに詳細な解説と一緒に例が載っています。

適当なデータを作成


適当な記事を書いて公開してください。プレースホルダに『ポラーノの広場』を使うとなんかデザイナー感出る。フォントに詳しそう。

microCMSと接続する

npm i microcms-js-sdk


ライブラリを追加。


APIキーをコピーします。

echo MICROCMS_SERVICE_DOMAIN=あなたのmicroCMSサービス名 >> .env.local
echo MICROCMS_API_KEY=$(pbpaste) >> .env.local


環境変数を書き出し。Linuxだとpbpasteじゃなくてxclip -selection clipboard -o を使えばいいそうです(ソース)

あと、クライアントサイドで使わないのでconfigで環境変数を登録する必要はありません。

mkdir lib && touch $_/microcms.ts


microcms.ts を作ります。

import { createClient } from 'microcms-js-sdk'

export const client = createClient({
  serviceDomain: process.env.MICROCMS_SERVICE_DOMAIN!,
  apiKey: process.env.MICROCMS_API_KEY!,
})


これでデータ取得の用意が整いました。

で、ここでReact 18からSSRでもSuspenseが...とか語ってたらキリがないので、普通にSSRして、静的にページを書き出せる形で作ります。

(React世界にはいろいろなデータフェッチ方法があるよ!調べてみよう!)

型定義

microcms-typescript っていうライブラリが新しいAPIに対応していなかったので、公式のSDKを使います。

API_NAME=article
mkdir types && touch $_/article.ts


API_NAME はあとでも使います。

import type { MicroCMSDate, MicroCMSImage, MicroCMSListResponse } from 'microcms-js-sdk'

export type ArticleType = {
  id: string
  title: string
  slug: string
  thumbnail?: MicroCMSImage
  body: string
} & MicroCMSDate

export type ArticleListType = MicroCMSListResponse<Article>


MicroCMSListResponsetotalCount などを付けます。それ以外の部分を自動で書いてくれるライブラリを見つけられませんでした。(aspidaでもここは手動)

記事コンポーネントを作る

ORGANISMS_DIR=./components/organisms
mkdir -p $ORGANISMS_DIR && touch "_/$(echo "$API_NAME" | sed 's/.*/\u&/').tsx"


Atomic Designガン無視で突然「Organisms(生体)」が登場しましたね。宇宙の 法則が 乱れる!

あと、イキりすぎて全部コマンドで済ませようとしていますね、何をしているかというとcomponents/organisms/(API名の先頭だけ大文字).tsx ってファイルを作っているだけです。大文字にする方法は色々あるそうなんですが、bashのバージョン云々が面倒なので置換してしまいました。

import { ArticleType } from '../../types/article'

export default function Article(props: ArticleType) {
  return (
    <article>
      <h1>{props.title}</h1>
      {props.thumbnail && <img src={props.thumbnail.url} alt={`${props.title}のサムネイル`} />}
      <div dangerouslySetInnerHTML={{ __html: props.body }}></div>
    </article>
  )
}


記事コンポーネントです。Atomic Designガン無視で生体の中身を書いてしまいましたが、説明のためなので見逃してください。

記事一覧を作る

touch "$ORGANISMS_DIR/$(echo "$API_NAME" | sed 's/.*/\u&/')List.tsx"


ArticleList.tsx を作ります。

import { ArticleListType } from '../../types/article'

export default function ArticleList(props: ArticleListType) {
  return (
    <div>
      <ul>
        {props.contents?.map(
          (p) =>
            p && (
              <li key={p?.id}>
                <a href={`/articles/${p.slug}`}>{p.title}</a>
              </li>
            )
        )}
      </ul>
    </div>
  )
}


最低限の記事リストです。

一覧ページを作成する

それではNext.jsの真骨頂、SSRにおける型の推測をお見せしましょう。

import type { InferGetStaticPropsType, NextPage } from 'next'
import Head from 'next/head'
import ArticleList from '../components/organisms/ArticleList'
import { client } from '../lib/microcms'
import { ArticleType } from '../types/article'

export const getStaticProps = async () => {
  const articlesData = await client.getList<ArticleType>({
    endpoint: 'article',
    queries: {
      limit: 10,
      orders: 'publishedAt',
    },
  })

  return {
    props: { articlesData: articlesData ?? null },
    revalidate: 10,
  }
}

type Props = InferGetStaticPropsType<typeof getStaticProps>

const Home: NextPage<Props> = (props) => {
  return (
    <div>
      <Head>
        <title>記事は{props.articlesData.totalCount}あります</title>
      </Head>
      <main>{props.articlesData ? <ArticleList {...props.articlesData} /> : <div>記事を取得できませんでした。</div>}</main>
    </div>
  )
}

export default Home


InferGetStaticPropsType<typeof getStaticProps> で、getStaticProps の返り値の方をReactのchildren と混ぜます。



するとHomepropsprops.articlesData が追加され、サーバーサイドの型をわざわざコンポーネント用に再度書く必要がなくなります。


こんな感じにタイトルに反映されます。

また、revalidate: 10 でISRが有効になり、適切なデプロイ環境なら10秒ごとにデータのキャッシュが切れます(10秒ごとにサーバーが動くわけではない。ここ重要)。

記事単体ページを作る

mkdir -p pages/${API_NAME}s && touch $_/\[slug\].tsx


[slug].tsxparams.slug を変数として取ります。

import type { GetStaticPaths, GetStaticPropsContext, InferGetStaticPropsType, NextPage } from 'next'
import Head from 'next/head'
import Article from '../../components/organisms/Article'
import { client } from '../../lib/microcms'
import { ArticleType } from '../../types/article'

export const getStaticPaths: GetStaticPaths = async () => {
  const allArticles = await client.getList<ArticleType>({
    endpoint: 'article',
    queries: {
      limit: 10,
      fields: ['slug'],
    },
  })
  const paths = allArticles.contents.map((c) => {
    return {
      params: {
        slug: c.slug,
      },
    }
  })
  return { paths, fallback: true }
}

export const getStaticProps = async ({ params }: GetStaticPropsContext) => {
  const articlesMatched = await client.getList<ArticleType>({
    endpoint: 'article',
    queries: {
      limit: 1,
      filters: 'slug[equals]' + params?.slug,
    },
  })

  if (articlesMatched.contents[0]) {
    return {
      props: { articleData: articlesMatched.contents[0] ?? null },
      revalidate: 10,
    }
  }
  return {
    props: {
      articleData: null,
    },
    notFound: true,
    revalidate: 10,
  }
}

type Props = InferGetStaticPropsType<typeof getStaticProps>

const ArticlePage: NextPage<Props> = (props) => {
  if (props.articleData) {
    const { title } = props.articleData
    return (
      <div>
        <Head>
          <title>{title}</title>
        </Head>
        <main>
          <Article {...props.articleData} />
        </main>
      </div>
    )
  }
  return <div>記事を取得できませんでした。</div>
}

export default ArticlePage


長いので分けて説明。

getStaticPaths

export const getStaticPaths: GetStaticPaths = async () => {
  const allArticles = await client.getList<ArticleType>({
    endpoint: 'article',
    queries: {
      limit: 10,
      fields: ['slug'],
    },
  })
  const paths = allArticles.contents.map((c) => {
    return {
      params: {
        slug: c.slug,
      },
    }
  })
  return { paths, fallback: true }
}


Next.jsのgetStaticPropsで、動的にURLを生成するための関数です。client.getList<ArticleType> で一覧からslugだけを取り出し、それらを[{params: slug: "スラッグ"}] といった配列に収めてpaths に返します。

fallback: true は、初回生成時にないページに訪れても、データを取得する設定です。正直ブログだと必要ないのですが、公開した記事を即見れるようにしたい場合に便利です。

getStaticProps

export const getStaticProps = async ({ params }: GetStaticPropsContext) => {
  const articlesMatched = await client.getList<ArticleType>({
    endpoint: 'article',
    queries: {
      limit: 1,
      filters: 'slug[equals]' + params?.slug,
    },
  })

  if (articlesMatched.contents[0]) {
    return {
      props: { articleData: articlesMatched.contents[0] ?? null },
      revalidate: 10,
    }
  }
  return {
    props: {
      articleData: null,
    },
    notFound: true,
    revalidate: 10,
  }
}

microCMSには2022年2月13日現在ユニークなフィールドからコンテンツ1つを返すAPIが用意されていません。そのため、filters: 'slug[equals]' + params?.slug で絞り込んで長さ0~1の配列をもらい、記事の有無を見ています。Strapiとか使えば自前でAPI作れるから楽しいんですけど、microCMSでは2022年2月13日現在こうするしかありません。

あと、params.slugの型は本来string | string[] | undefined ですから、文字列じゃない可能性を考慮するべきですが、文字列とプラスして文字列にしてしまえばMicroCMSQueries.filters?: string | undefined とたまたま合致し、型チェックが通ります。

しかもURL構造上配列になりえない(クエリパラメータじゃないのでカンマ書いても配列にならない)ので、これで動作に支障はきたしません。



notFound: true を返せば404になります。もちろん404ページはカスタマイズ可能です。

ページコンポーネント

type Props = InferGetStaticPropsType<typeof getStaticProps>

const ArticlePage: NextPage<Props> = (props) => {
  if (props.articleData) {
    const { title } = props.articleData
    return (
      <div>
        <Head>
          <title>{title}</title>
        </Head>
        <main>
          <Article {...props.articleData} />
        </main>
      </div>
    )
  }
  return <div>記事を取得できませんでした。</div>
}

export default ArticlePage


titleだけはページで使いたいので取り出します。ページ以外でheadをいじるのは人間のやることではない。

画像をNext/Imageにする


Do not use <img>. Use Image from 'next/image' instead. See: https://nextjs.org/docs/messages/no-img-element

next/core-web-vitals っていうeslintプラグインが追加されていて、普通のimgタグだとwarnレベルの警告が出ます。このままでもビルドはできますが、練習がてらNext/Imageを使ってみましょう。

next.config.js

  images: {
    domains: ['images.microcms-assets.io'],
  },


以上の設定を加えてください。microCMS以外の画像を使う場合は、適宜追加してください。

mkdir ./components/atoms && touch $_/MicroCMSImage.tsx


やっと原子を作ります。順番がおかしい。

MicroCMSImage.tsx

import type { MicroCMSImage } from 'microcms-js-sdk'
import Image from 'next/image'

export default function MicroCMSImage(props: { image: MicroCMSImage; alt: string }) {
  const { width, height, url } = props.image
  return <Image width={width} height={height} src={url} alt={props.alt} />
}


next/image用にコンポーネントを分けるのは、サイズの取得が面倒だからです。

Article.tsx

import { ArticleType } from '../../types/article'
import MicroCMSImage from '../atoms/MicroCMSImage'

export default function Article(props: ArticleType) {
  return (
    <article>
      <h1>{props.title}</h1>
      {props.thumbnail && <MicroCMSImage image={props.thumbnail} alt={`${props.title}のサムネイル`} />}
      <div dangerouslySetInnerHTML={{ __html: props.body }}></div>
    </article>
  )
}


これで画像部分を最適化できました。



HTMLがすごいことになってたら成功です。

参考

取得に失敗しました

2021年度 入部

Twitter GitHub