🖥️

Next.js + microCMSでホームページを作った時に躓いた話

2021/12/05に公開約10,000字

ご挨拶

初めまして。
バーチャルWebエンジニアを名乗っている三波ヨタと申します。
普段はTwitterやYouTubeで活動しております。
今回、初のアドベントカレンダーの参加になります。
よろしくお願いします。

この記事はmicroCMS Advent Calender 2021 5日目の記事です。

作ったもの

今回は自分の宣伝もかねて、三波ヨタの公式サイトを作成してみました。

https://yotta-site.vercel.app/

技術構成

  • Next.js 11.1.2
  • TypeScript 4.4.4
  • CSS Modules
  • @mui/material 5.0.4
  • microCMS
  • Vercel

なぜ(この技術で)作ったのか

  • 自分のサイトがあるとなんかカッコいい
  • お仕事を探す時に使うポートフォリオ作品の1つとして
  • 普段ReactとTypeScriptを触っているのでNext.jsを使って見たかった
  • SSGにも興味があったので調べているとmicroCMSに出会った
  • microCMSの公式ブログを見てみると簡単に使えそうなので選定(日本語なのも大きい)
  • サイト作成中にちょうどNext.jsの12が発表されたのでVercelを使ってみたい
  • Next.jsのチュートリアルがCSS Modulesだったので推してるのかなと
  • ページネーションの自作がめんどくさいのでMUI

と、こんな感じです。

この記事で話すこと

Next.js + microCMSでのブログ作成記事は、いろんな人が分かりやすい記事を書いてくれているので割愛します。

なので今回は私がサイト制作で詰まったところや、実装していてテンションが上がった事を書いてみようと思います。

因みに、ブログの作成時に参考にさせていただいたサイトはこちらです。

https://blog.microcms.io/microcms-next-jamstack-blog/

eslintのエラー

まずはeslintのエラーです。

今回はNext.jsのバージョン11.1.2を使用したのですが、npx create-next-app@11.1.2 AppName --tsで環境を構築すると、eslintのバージョンが8.2.0でインストールされるようです。

このままyarn lintをすると下記のようなエラーが出ました。

error - ESLint version 8.2.0 is not yet supported. Please downgrade to version 7 for the meantime: yarn remove eslint && yarn add --dev eslint@"<8.0.0"

Next.jsはまだeslint 8 はまだ対応していなかった様ですね。

https://github.com/vercel/next.js/issues/30323

なので、eslint 7.32.0をインストールし直してみると、yarn lintは問題なく動くようになりました。

ちなみに、この記事を書いている時点(2021/11/17)での最新のNext.jsをインストールすると、デフォルトでeslint 7が設定されている様になっていました。

なので、Next.js12はインストールしたら何の設定もなくlintが使えます。

ページネーション

ブログの一覧ページがあればページネーション機能を実装したくなります。

ページネーションの実装はmicroCMSの公式ブログに書かれていたのですが、「次のページへ」などが無かったので(多分)、自作しようと考えました。

ですが、一から実装するのはとてもめんどくさかったので、MUIのページネーションを使用して解決しました。

ページネーションの実装を一部抜粋

/page/blog/page/[id].tsx
import React, { FC } from "react";
import Router from "next/router";
import styles from "../../Home.module.scss";

const PER_PAGE = 9;
const PAGE_NAME = "blog";
const BlogPageId: FC = ({
  blog,
  id,
  count,
}) => {
  const handleClick = (_: { preventDefault: () => void }, page: number) => {
    Router.push(`/${PAGE_NAME}/page/${page}`);
  };

  return (
    <div className={styles.root}>
      {/* コンテンツ */}
      <div>
        {blog.map((item) => (
          <BlogItem item={item} key={item.id} />
        ))}
      </div>
      {/* ページネーション */}
      <Pagination
        count={count}
        page={Number(id)}
        onChange={handleClick}
        showFirstButton
        showLastButton
        className={styles.pagination}
      />
    </div>
  );
};

export default BlogPageId;

export const getStaticPaths = async () => {
  // 省略
};

export const getStaticProps = async (context: { params: { id: number } }) => {
  const id = context.params.id;
  const data = await fetcher(`/${PAGE_NAME}`, {
    offset: (id - 1) * PER_PAGE,
    limit: PER_PAGE,
  });
  const count = Math.ceil(data.totalCount / PER_PAGE);

  return {
    props: {
      blog: data.contents,
      id,
      count,
    },
  };
};

ポイントといえば、PaginationのClickイベントの引数にクリックしたページ番号が取得できるので、それをもとにnext/routerで画面遷移するだけです。

めちゃ簡単!

レスポンシブについて

Reactでレスポンシブを実装する時によくreact-responsiveが使われるらしいですが、Next.jsで使うと期待通りの動きをしませんでした。

具体的には、PCとSPでコンポーネントを出し分ける時、初期表示に必ずデフォルトのコンポーネントが表示されてしまいました。
一度ブレイクポイントが切り替わればその後は正常に動くようになりますが、初期表示だけ期待通りの動きをしませんでした。

なので別の方法を探していると、MUIにもレスポンシブのhooksが用意されている事を知りました。

MUIはページネーションコンポーネントを使うために選定しましたが、せっかく使用しているのでMUIのuseMediaQueryを使用してレスポンシブの問題を解決しました。

レスポンシブの実装を一部抜粋

Header.tsx
import useMediaQuery from "@mui/material/useMediaQuery";
import Drawer from "@mui/material/Drawer";
import { FaTimes } from "react-icons/fa";
import styles from "./Header.module.scss";

const ResponsiveMenu: FC = ({
  open,
  children,
  onClose,
}) => {
  const matches = useMediaQuery("(max-width:991px)");

  return matches ? (
    <Drawer open={open} anchor="right" onClose={onClose}>
      <div className={styles.drawerHeader}>
        <FaTimes size={30} onClick={onClose} />
      </div>
      {children}
    </Drawer>
  ) : (
    children
  );
};

useMediaQueryの引数にメディアクエリの条件を書くだけでマッチするかをbooleanで返してくれます。

これもイメージしやすくて使いやすいです!

お問い合わせ機能の実装

今までブログサイトを作成するにあたって、お問い合わせ機能の実装といえばWordPressのContactForm7やphpで独自実装、外部サービスを埋め込むなどがよく言われてきたイメージがあります。

しかし、これらの方法はデザインが制限されていたり、セキュリティに不安があったことでしょう。

ですが、microCMSなどのヘッドレスCMSを使えば見た目は自分のイメージ通りに作れますし、バリデーションも思いのままです。

お問い合わせ機能はこちらの記事を参考に実装しました。

https://ji23-dev.com/blogs/next-use-microcms-contact

お問い合わせ機能を実装する課題点として、APIキーを公開しない様に気を付けなければいけません。

Next.jsで環境変数を使うとなると、よく「NEXT_PUBLIC_の接頭辞をつけよう」と紹介されていますが、これではサイト上にAPIキーが公開されてしまいます。
例えば、POSTした時にブラウザのdevtoolでネットワークタブを確認してみると、POSTのAPIキーが確認できます。

なのでNEXT_PUBLIC_を付けずに環境変数を定義するのですが、こうするとフロントから環境変数を参照することができなくなります。

NEXT_PUBLIC_をつけるとAPIキーが公開されてしまうし、NEXT_PUBLIC_を付けないとフロントで参照できないジレンマに出くわします。

そこで、API Routesを使用します。

API Routesについてはこちら。

https://nextjs-ja-translation-docs.vercel.app/docs/api-routes/introduction

pages/api/配下にファイルを作成することで、apiを用意することができます。

なので、contact.tspages/api/に作成して下記を記載(一部抜粋)。

pages/api/contact.ts
const content = await fetch(`${API_ENDPOINT}/contact`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-WRITE-API-KEY": WRITE_API_KEY,
  },
  body: JSON.stringify(req.body),
})
  .then(() => "Created")
  .catch(() => null);

そしてフロント側のコードは、自分が作成したapiにpostします。

コンタクトコンポーネントを一部抜粋。

ContactSection.tsx
fetch("/api/contact", {
  method: "post",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify(formData),
})
  .then((response) => {
    if (!response.ok) {
      console.error("サーバーエラー");
      return;
    }
    // formをリセット
    reset({
      name: "",
      email: "",
      body: "",
    });
  })
  .catch((error) => {
    console.error("通信に失敗しました", error);
  });

こうすることで、フロントからAPIキーを参照できなくても、api側で処理をすることでPOSTのAPIキーを隠したまま通信が行えます。

SSGとAPI Routesについて

Next.jsを選定した理由の1つとして、サイトをSSGしてJamStack構成にしたかったからです。

そのためnext exportをしていたのですが、ここで困ったポイントが出てきました。

それは、開発環境ではAPIが動いていたのに、デプロイするとAPIが動いておらず404になってしまうということです。

結論、next exportをするとAPI Routesは動きません。

ドキュメントに書かれていました。
読んでいない私が悪いです。はい。

API Routes can't be used with next export

https://nextjs.org/docs/api-routes/introduction#caveats

なので、最終的にnext exportは使わずにVercelにデプロイすることにしました。
そうすることでAPI Routesは正常に動くようになったので、無事、お問い合わせ機能を実装できました。

ということで、本来やりたかった構成は諦めてしまいましたが、Next.jsの機能を活かしてサイトを制作することができて、個人的には満足です。

とはいえ、githubのissuesやdiscussionsを漁っていると私と同じような現状の人がちらほらいました。

その人のコード見たわけではないので全く違う原因かもしれませんが、私みたいな人が1人でも助かれば良いなと思います。

お問い合わせ機能の実装で思ったこと

今回はNext.jsとmicroCMSで完結させたかったので開発中は気づきませんでしたが、自前のサーバーを別に用意して、APIキーをサーバー側で管理すれば、表側はnext exportで完全に静的ページ構成にできるなと思いました。

そして、単純にPOSTするだけなら<form>タグで書いた方がシンプルかなとも思いました。

SEOについて

ブログ機能を実装して、記事を書いたらSNSで共有したくなると思います。
その時、OGP設定で画像を登録していないとちょっと残念な感じになります。

また、SEO対策も最低限しておきたいということで、いろいろ設定してみることにしました。

今回は下記記事を参考にさせていただきました。

https://qiita.com/TK-C/items/cd34d0f6d4b001053443

microCMSにseoのapiを追加して、この様なスキーマで作成しました。

あとは、getStaticPropsでデータを取得し、seoコンポーネントに突っ込むだけです。

肝心なseoコンポーネントはこんな感じです。(一部抜粋)

Seo.tsx
const Seo: FC<SeoProps> = ({ data }) => {
  // 100文字を超えたら...を追加する
  const threePointLeader = data.description.length < 100 ? "" : "...";
  const description =
    data.description.substr(0, 100) + threePointLeader;

  return (
    <Head>
      <title>{data.title}</title>
      <meta name="description" content={description} />
      <meta property="og:title" content={data.title} />
      <meta property="og:url" content={data.url} />
      <meta property="og:site_name" content={data.title} />
      <meta property="og:description" content={description} />
      <meta property="og:type" content="website" />
      <meta property="og:image" content={data.image.url} />
      <meta name="twitter:card" content="summary_large_image" />
      <meta name="twitter:site" content={data.url} />
      <meta name="twitter:url" content={data.url} />
      <meta name="twitter:title" content={data.title} />
      <meta name="twitter:description" content={description} />
      <meta name="twitter:image" content={data.image.url} />
      <link rel="canonical" href={data.url} />
    </Head>
  );
};

トップページのmetaタグはこんな感じで固定の内容で良いのですが、ブログや作品の記事は内容が変わるように可変にしたいところです。

ということで、各[id].tsxでデータを整形してpropsに渡します。
例としてブログページの整形を一部抜粋します。

pages/blog/[id].tsx
// 省略
export const getStaticProps: GetStaticProps = async (context) => {
  const endpoint = `/blog/${context.params?.id}`;
  const blogData = await fetcher<BlogData>(endpoint);
  const { url: seoUrl } = await fetcher<SeoData>("/seo");
  // NOTE: SEOの文字列として表示するためiframeを削除した後にタグを削除
  const seoDescription = blogData.body
    .replace(/<iframe.*<\/iframe>/, "")
    .replace(/<("[^"]*"|'[^']*'|[^'">])*>/g, "");
  // ここでmicroCMSから取得した個別のデータをビルド時に詰める
  const seoData: SeoData = {
    title: `${blogData.title} | 三波ヨタのブログ`,
    description: seoDescription,
    url: seoUrl + endpoint,
    image: blogData.image,
  };
  return {
    props: {
      blogData,
      seoData,
    },
  };
};

ホスティングサービスとmicroCMSを連携して自動ビルドにしておけば、cms側でブログを更新するだけでSEOの設定も簡単に設定することができます。

今回は記事の最初100文字を設定しましたが、seo専用のフィールドを用意して、しっかりとした文を書いたほうが良いと思います。

今後の課題とやりたいこと

今回は、まずは動かして公開することを目標にしたので、課題ややりたいことがたくさん残っています。

  • プレビュー機能を導入したい
  • getStaticPropsでの記事の取得を1000件にしているので、それを超えると古い記事が取れない
    • 上限を増やせばよいかもしれないが、取得できる上限が5メガらしいので、分割して取得したい
  • 動くことを最優先に作ったのでコードのリファクタリングをしていきたい
  • CSS Modulesが初の試みだったのですが、ファイルの管理がよくわからなかったのでリファクタしたい
    • コンポーネント単位でtsxとscssを1フォルダにまとめるのがよいか?
  • お問い合わせにrecaptchaを追加してみたい、が、今のところ迷惑メールが届いていないので、まあいっか

まとめ

Next.js + microCMS はいいぞ!

更新

  • 2021/12/6 SSGとAPI Routesについての文章を修正

Discussion

ログインするとコメントできます