Next.js × microCMSでサイトを運営する際にやっておきたいSEO対策

この記事は、「[Jamstack Advent Calendar 2020](https://qiita.com/advent-calendar/2020/jamstack)」の18日目の記事です。

## はじめに
おはようございます、こんにちは、こんばんは。カルキチ副島です。
プログラミングの学習をはじめて1年半、Qiitaは今まで見る専だったのですが、初めてQiitaに記事を投稿してみることにしました。

今回は、Next.js × microCMS × SEO対策（主に内部SEO周り）という内容で記事を書いてみることにしました。

## Next.js × microCMS × SEO対策で記事を書こうと思った理由
Next.js × microCMSでJAMstackなブログやサイトを作ろうみたいな記事は結構見かけるけど、SEO対策（主に内部SEO）まで踏み込んだ内容の記事はあまり見ないと感じたからです。

PV数の増加や質の高いユーザーにサイトを訪問してもらうには、SEO対策が重要であることは、ご存知の方も多いかと思いますが、SEO対策をがっつりやるのは**結構な手間と時間がかかります。**

タイトルやデスクリプションの設定とかならすぐできますが、タイトル・デスクリプションの設定しか行っていないサイトは、個人レベルで運営しているサイトならまだしも、収益を狙いにいくようなある程度規模の大きいサイトだとほぼないと思います。

それなりに規模の大きいサイトがやってることが多いと感じるSEO対策をざっとですが、挙げてみました。

* **タイトル・デスクリプションの設定**
* **パンくずリストの表示**
* **HTMLサイトマップの設置**
* **OGP（Open Graph Protocol）の設定**
* **GA（Google Analytics）の設定**
* **サチコ（Google Search Console）の設定**

上記に加え、大規模なサイトや内部SEO対策をガチガチにやってるサイトだと、**sitemap.xml**や**構造化データ**も設定されていることが多いです。

個人的な見解ではありますが、個人レベルのサイトやブログならタイトルやデスクリプション、OGP、GAあたりまで設定しておけば十分な気はします。。。

## 基本的なSEO対策について
### タイトル・デスクリプションの設定
タイトルとデスクリプションの設定をしていない人は流石にいないかとは思いますが、一応。。。

Next.jsでの設定方法ですが、``next/head``を使って、親から子コンポーネントにタイトルとデスクリプションをpropsで渡せば、ページごとのタイトルやデスクリプションを簡単に設定できます。

```react:Head.tsx
import Head from 'next/head'

interface Props {
  title: string;
  description: string;
}

export default ({ title, description }: Props): JSX.Element => {
  return (
    <Head>
      <title>{title}</title>
      <meta name="description" content={description} />
    </Head>
  )
}
```

デスクリプションは設定を行わなくても、Googleが自動的に検知して表示してくれますが、**意図しない文章がデスクリプションとして設定される可能性があります。**

そういった事態を防ぐためにも、デスクリプションを設定するためのフィールドを作っておき、Next.js側でデスクリプションを取得して設定するようにするのがおすすめです。

![スクリーンショット 2020-12-04 0.17.36.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375389/803a4f38-a7ae-7410-4219-296bdca4574d.jpeg)

### パンくずリストの表示
ウェブサイトの構造をユーザーに視覚的に分かりやすく伝えることができるパンくずリストは、検索エンジンのクローラーにサイトの構造を正確に伝えることができるので、SEO的効果があると言われています。

サイトの回遊性もアップし、ユーザビリティの向上にも繋がるので、実装するメリットは大きいと思われます。

試しに、以下のようなパンくずリストを作ってみました。

パンくずのサンプルはこちらです！
* [記事ページのパンくずの例](https://karukichi-blog.netlify.app/blogs/docker-webpack-dev-server)
* [カテゴリーページのパンくずの例](https://karukichi-blog.netlify.app/category/front-end)

```react:Breadcrumb.tsx
import Link from 'next/link';
import { useRouter } from 'next/router';
import React from 'react';
import styled from 'styled-components';

interface Props {
  blogPageInfo?: {
    categoryId: string;
    categoryName: string;
    blogTitle: string;
  };
  pageTitle?: string;
}

const Breadcrumb: React.FC<Props> = ({ blogPageInfo, pageTitle }) => {
  const router = useRouter();
  const path = router.asPath;

  const isBlogPage = /\/blogs\/.+$/.test(path);

  return (
    <MyBreadcrumb>
      <BreadcrumbItem>
        <Link href="/">
          <a>サイト名</a>
        </Link>
      </BreadcrumbItem>
      {isBlogPage && (
        <>
          <BreadcrumbItem>
            <Link href="/category/[id]" as={`/category/${blogPageInfo?.categoryId}`}>
              <a>{blogPageInfo?.categoryName}</a>
            </Link>
          </BreadcrumbItem>
          <BreadcrumbItem>{blogPageInfo?.blogTitle}</BreadcrumbItem>
        </>
      )}
      {pageTitle && <BreadcrumbItem>{pageTitle}</BreadcrumbItem>}
    </MyBreadcrumb>
  );
};

const MyBreadcrumb = styled.ul`
  margin-bottom: 20px;
  list-style: none;
`;

const BreadcrumbItem = styled.li`
  position: relative;
  display: inline;
  margin-right: 20px;
  a {
    color: #331cbf;
    &:hover {
      text-decoration: underline;
    }
  }
  &::after {
    position: absolute;
    content: '>';
    bottom: -1px;
    right: -15px;
  }
  &:last-child::after {
    content: '';
  }
`;

export default Breadcrumb;
```
記事ページの場合、**サイト名→カテゴリー→記事タイトル**と3階層になっています。
そのため、記事タイトルに加えて、記事が属するカテゴリー名とリンクも設置する必要があるので、表示されているページが記事ページかどうか判定する処理を入れる必要があります。

カテゴリーページやタグページ、お問い合わせなどは、２階層しかなく、記事のリンクは不要なのでページのタイトルのみを表示するようにしています。

```
  const router = useRouter();
  const path = router.asPath;
  const isBlogPage = /\/blogs\/.+$/.test(path);
```

``router.asPath``の部分でパスを取得して、ブラウザに表示されるパスの中に、**blogs（記事ページのみ存在する）という文字列があるかどうかで判定を行っています。**


### サイトマップの表示
HTMLサイトマップは設置すること自体が直接的にSEOに結びつくわけではありませんが、検索エンジンにサイト内のページを認識させることができるので、質の高いコンテンツであれば、**検索結果の上位表示を狙いやすくなると言われています。**

ユーザー目線で見ても、どんな記事があるのかサイトの全体像を視覚的に把握しやすくなるので、設置するメリットは大きいと言えそうです。

以下のコードはカテゴリーとカテゴリー配下の記事を取得して、HTMLサイトマップを生成するコードのサンプルです。

```react:sitemap.tsx
import { GetStaticProps } from 'next';
import Link from 'next/link';
import React from 'react';
import styled from 'styled-components';

import { Sitemap } from '../../interfaces/sitemap';

import Breadcrumb from '../components/Breadcrumb';
import Head from '../components/Head';
import Layout from '../components/Layout';

interface Props {
  contents: Sitemap[];
}

const BlogSitemap: React.FC<Props> = ({ contents }) => {
  const siteTitle = 'サイト名';
  const pageTitle = 'サイトマップ';
  const title = `${siteTitle}｜${pageTitle}`;

  return (
    <Layout>
      <Head title={title} />
      <Breadcrumb pageTitle={pageTitle} />
      <h1>{pageTitle}</h1>
      <SitemapDiv>
        {contents.map((content) => (
          <ul key={content.id}>
            <li>
              <Link href="/category/[id]" as={`/category/${content.id}`}>
                <a>{content.name}</a>
              </Link>
            </li>
            <PostList>
              <ul>
                {content.posts.map((post) => (
                  <li key={post.id}>
                    <Link href="/blogs/[id]" as={`/blogs/${post.id}`}>
                      <a>{post.title}</a>
                    </Link>
                  </li>
                ))}
              </ul>
            </PostList>
        ))}
      </SitemapDiv>
    </Layout>
  );
};

export const getStaticProps: GetStaticProps = async () => {
  const key = {
    headers: {
      'X-API-KEY': process.env.API_KEY || '',
    },
  };

  const params = `?fields=id,name,posts.id,posts.createdAt,posts.title&limit=9999`;
  const res = await fetch(`https://your-service.microcms.io/api/v1/category${params}`, key);
  const data = await res.json();

  const contents: Sitemap[] = data.contents;

  contents.map((blog) => {
    const posts = blog.posts;

    posts.sort((a, b) => {
      return a.createdAt < b.createdAt ? 1 : -1;
    });
  });

  return {
    props: {
      contents: contents,
    },
  };
};

const SitemapDiv = styled.div`
  margin-top: 30px;
  ul {
    margin-bottom: 20px;
    a {
      color: #331cbf;
    }
  }
`;

const PostList = styled.li`
  list-style: none;
  ul {
    margin-left: 20px;
    li {
      line-height: 190%;
    }
  }
`;

export default BlogSitemap;
```

APIスキーマの型はこんな感じです。

```typescript:sitemap.ts
export interface Sitemap {
  id: string;
  name: string;
  posts: {
    id: string;
    createdAt: string;
    title: string;
  }[];
}
```

postsのスキーマを**複数コンテンツ参照（ブログの記事）**にすることで、各カテゴリーに属する記事を全て取得できるようにします。

![スクリーンショット 2020-12-05 19.57.53.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375389/2cd3b502-29c5-0025-cb48-3ec3db7c6125.jpeg)


microCMSで作成したカテゴリーを取得するAPIの中からサイトマップを生成するのに必要なデータ（カテゴリーの名前とパス、カテゴリーに属する記事タイトルとURL）を取得して、ループして表示しているだけなので、比較的簡単に実装できるのかなと思います。

[サイトマップ](https://karukichi-blog.netlify.app/sitemap)は、こんな感じで表示されます。

![スクリーンショット 2020-12-05 2.20.14.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/375389/e39dbc71-15bb-a968-ad41-1f6e411715d9.jpeg)

### OGPの設定
**OGP（Open Graph Protcol）**とは、TwitterやFacebookなどのSNSでシェアをした時に、ウェブページのタイトルや概要をユーザーに伝えるためのHTML要素を生成することができるものです。

アイキャッチの設定もできるので、どんな記事なのかを視覚的にユーザーにわかりやすく伝えることができます。

SEO的な効果ですが、OGPの設置は以下のような効果があるそうです。

>OGP設定はSEO上の直接的なメリットはありませんが、訴求改善によるCTR向上やSNSでの拡散による良質な被リンクを受けるといった間接的なSEO効果が期待できます。
出典：[OGPを設定することによるメリットを教えてください。 | αSEO(アルファSEO)](https://edit.roaster.co.jp/web/2658/)

**拡散されやすくなる→アクセス数が増える→結果として検索上位に上がりやすくなる**といった感じでしょうか。
HTMLサイトマップと同様に、OGPの設定も間接的ではありますが、SEO対策に繋がると言えそうです。

OGPの設定方法ですが、OGPを生成するためのHTMLタグをheadに貼り付けるだけでできるので、簡単にできるというのもポイント高いです。
→OGP用の画像を用意すると言う手間はかかりますが、、、

以下のコードをhead内に貼り付けることで、OGPの設定ができます。

```
<meta property="og:type" content="ページの種類">
<meta property="og:url" content="ページのURL">
<meta property="og:image" content="アイキャッチのURL">
<meta property="og:title" content="タイトル">
<meta property="og:description" content="デスクリプション">
<meta property="og:locale" content="言語の指定">
```

OGPを表示するのに書くタグなのですが、サイトによって結構バラバラだったので、今回は検索順位1位のものを採用させていただきました。
→OGPの正しい設定方法を知っている方は教えていただけると嬉しいです。

参考サイト
https://mirai-creators.com/2199/#MenuTitle_1

僕が作ったJAMstackブログでは、こんな感じでOGPの設定を行なっています。

```react:Head.tsx
import Head from 'next/head'

interface Props {
  title: string;
  description: string;
}

export default ({ title, description, url, thumbnail }: Props): JSX.Element => {
  const ogpImage = '記事ページ以外のOGPイメージのURL';
  const defaultDescription = '記事ページ以外で設定されるデスクリプション';

  const router = useRouter();
  const path = router.asPath;
  const isBlogPage = /\/blogs\/.+$/.test(path);

  return (
    <Head>
      <title>{title}</title>
      <meta property="og:locale" content="ja_JP" />
      <meta property="og:title" content={title} />
      <meta property="og:url" content={url} />
      <meta name="twitter:card" content="summary_large_image" />
      <meta name="twitter:site" content="自分のTwitterのユーザー名" />
      {isBlogPage ? (
        <>
          <meta name="description" content={description} />
          <meta property="og:description" content={description} />
          <meta property="og:image" content={thumbnail} />
        </>
      ) : (
        <>
          <meta name="description" content={defaultDescription} />
          <meta property="og:description" content={defaultDescription} />
          <meta property="og:image" content={ogpImage} />
        </>
      )}
    </Head>
  )
}
```

記事ページ以外のページはデフォルトのデスクリプションとアイキャッチが表示されるようになっていますが、記事ページの場合は固有のデスクリプションとアイキャッチをOGPに設定したいので、``isBlogPage``で記事ページかどうかの判定を入れることで、個別のアイキャッチとデスクリプションが設定されるようにしています。

説明が抜けていましたが、以下の記述はTwitterでのOGPイメージの設定を行うためのものです。

```
<meta name="twitter:card" content="Twitterカードの種類" />
<meta name="twitter:site" content="TwitterのID" />
```

ウェブサイトの場合は、```summary```か```summary_large_image```のどちらかを設定できます。

参考サイト
[Twitterカードについて](https://saruwakakun.com/html-css/reference/twitter-card)

### リダイレクト
ここでは、旧URLの評価の引き継ぎや、ページネーションの作成などによって生じる重複コンテンツの回避に使用される恒久的なリダイレクト（301、308）を行う方法について解説していきます。

#### Netlifyでリダイレクトを行う
Netlifyでリダイレクトを行う際は、netlify.tomlというnetlifyの設定ファイルを用意して、リダイレクトの設定を記述する必要があります。

リダイレクトの記述方法に関してはこちらの記事をご覧ください。
https://docs.netlify.com/configure-builds/file-based-configuration/#redirects

```
## 記事一覧
[[redirects]]
  from = "https://karukichi-blog.netlify.app/page/1"
  to = "https://karukichi-blog.netlify.app/"
  status = 301
  force = true

## カテゴリーに紐付く投稿一覧
[[redirects]]
  from = "/category/*/1"
  to = "/category/:splat"
  status = 301
  force = true

## タグに紐付く投稿一覧
[[redirects]]
  from = "/tags/*/1"
  to = "/tags/:splat"
  status = 301
  force = true

```

僕の作った技術ブログでは、ページネーションがあるページ（URLは違うけど同じ内容のページが存在する）の1ページ目にリダイレクトをかけています。

https://karukichi-blog.netlify.app/page/1
https://karukichi-blog.netlify.app/category/front-end/1
https://karukichi-blog.netlify.app/tags/javascript/1

アクセスすると、リダイレクトがかかっていることが確認できるかと思います。

#### Vercelでリダイレクトを行う
Vercelは今回の記事を書くにあたって、初めて触ってみたのですが、Vercelでリダイレクトを行う場合はvercel.jsonにリダイレクトの設定を記述すればできそうです。

https://vercel.com/docs/configuration

```
{
  "redirects": [
    {
      "source": "/about", 
      "destination": "/about/2"
    },
    {
      "source": "/category/:match*/1",
      "destination": "/category/:match*"
    }
  ]
}
```

``/about``にアクセスされた場合は、``/about/2``へ、``/category/カテゴリーの名前/1``にアクセスされた時は、``/category/カテゴリーの名前``にリダイレクトを飛ばすサンプルを作ってみました。

https://next-vercel-redirect-test-50u8meco5.vercel.app/about
https://next-vercel-redirect-test-50u8meco5.vercel.app/category/javascript/1

## Next.js × microCMSのSEO対策について感じたこと
Next.js × microCMSで内部SEO対策を行う場合、**実装者側がある程度のSEOの知見を持っている必要があるのかなと感じました。**

最も広く普及しているCMSとしてWordPressがありますが、WordPressで内部SEO対策を行う場合、基本的なSEO対策はプラグインを導入して管理画面上で設定項目を入力するだけで全て完結できてしまうので、内部SEO対策という面では、まだWordPressの方に分があるのかなと考えました。

逆の捉え方をすれば、内部SEO対策を簡単にできるようなものが出てくれば、今以上にJAMstackアーキテクチャのサイトは世に普及していくのかなと感じました。

## 余談
当初の予定では、構造化データやsitemap.xmlの生成、canonicalやページネーションのprev・relあたりの設定に関しても、この記事で書こうと考えていたのですが、想像以上に文字数が多くなってしまい、読んでる側が疲れそうだなと思ったので、上記に関しては個人でやってる技術ブログの方にそのうち書こうかなと考えています。

普段はこっちの技術ブログで色々書いています。

[カルキチのブログ](https://karukichi-blog.netlify.app/)