【ハンズオン】Next.jsとmicroCMSでブログサイトを作ろう！

## まえがき

以下の勉強会で使用したハンズオン資料を再編集したものです。

* [【ハンズオン】Next.jsとmicroCMSでブログサイトを作ろう (2021/04/14 19:30〜)](https://ueqareer.connpass.com/event/209069/)
* [【ハンズオン】Next.jsとmicroCMSでブログサイトを作ろう｜IT勉強会ならTECH PLAY［テックプレイ］](https://techplay.jp/event/813628)

# 【ハンズオン】Next.jsとmicroCMSでブログサイトを作ろう！

## Next.jsとは

https://nextjs.org/

### Reactを本番環境で利用するためのフレームワーク

- 本番環境に必要な設定が予め組み込まれている
    - Webpack、TypeScript、多言語対応、画像の最適化、サーバーサイド、静的生成、SEOなど
    - 環境構築に要する時間を短縮できる
- Vercelと連携して爆速でデプロイできる
    - Next.jsのデプロイに最適化されたホスティングサービス

https://vercel.com/

- もちろん他のクラウドやホスティングサービスでもデプロイ可能
    - Firebaseの例

https://qiita.com/takaken/items/8ed671486de804696079

### Next.jsを学ぶには

- Reactの知識が前提
    - [https://ja.reactjs.org/](https://ja.reactjs.org/)
- 公式のチュートリアル
    - [https://nextjs.org/docs/getting-started](https://nextjs.org/docs/getting-started)

※ こちらの記事で、Reactを学ぶハンズオンも紹介しています

https://qiita.com/takaken/items/032a25bbd83b80f4b0db

## MicroCMSとは

https://microcms.io/

### ヘッドレスCMSとは

https://blog.microcms.io/what-is-headlesscms/

### その他のヘッドレスCMS（一例）

- [contentful](https://www.contentful.com/)
- [strapi](https://strapi.io/)
- [Netlify CMS](https://www.netlifycms.org/)
- [Framelink](https://flamelink.io/)

# ハンズオン：Next.jsとmicroCMSでブログサイトを作ろう

## 基本編

完成版のソースコードはこちら

https://github.com/redimpulz/nextjs-microcms-blog

### 1. Next.js プロジェクトの作成

https://github.com/redimpulz/nextjs-typescript-starter

```bash
$ npx create-next-app nextjs-microcms-blog --example "https://github.com/redimpulz/nextjs-typescript-starter"
$ cd nextjs-microcms-blog
```

### 2. MicroCMSの用意

* [アカウント登録](https://document.microcms.io/manual/signup)
* [ログイン](https://document.microcms.io/manual/signin)
* [サービスの作成](https://document.microcms.io/manual/create-service)
* [APIの作成](https://document.microcms.io/manual/create-api)
  * エンドポイントは、 「blog」 に
  * APIの型は「リスト形式」に
  * APIスキーマは、以下のように

![Screenshot_from_2021-04-14_13-26-06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71453/64c9f9a1-1146-beeb-09e3-710aa14513ac.png)

### 3. APIクライアントの実装

HTTPクライアントのライブラリをインストール。
今回は、「ky」を使う。

```bash
$ yarn add ky@0.25.1 ky-universal@0.8.2
```

環境変数を追加。

```bash
MICRO_CMS_API_ENDPOINT=xxxxx
MICRO_CMS_API_KEY=xxxxx
```

環境変数の型定義を追加。

```tsx:types/global.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    // サーバーのみで扱う環境変数
    readonly MICRO_CMS_API_ENDPOINT: string;
    readonly MICRO_CMS_API_KEY: string;
  }
}
```

APIリクエスト時の共通処理を追加。

```tsx:utils/http/microcms.ts
import ky from 'ky-universal';

const http = ky.create({
  prefixUrl: process.env.MICRO_CMS_API_ENDPOINT,
  hooks: {
    beforeRequest: [
      (request) => {
        request.headers.set('X-API-KEY', process.env.MICRO_CMS_API_KEY);
      },
    ],
  },
});

export default http;
```

MicroCMSの共通レスポンスの型を定義。

https://document.microcms.io/manual/automatic-grant-fields

```tsx:types/microcms.ts
export type MicroCmsItemGetRes<T> = T & {
  id: string;
  createdAt: string;
  updatedAt: string;
  publishedAt: string;
  revisedAt: string;
};

export type MicroCmsListGetRes<T> = {
  contents: MicroCmsItemGetRes<T>[];
  totalCount: number;
  offset: number;
  limit: number;
};

export type MicroCmsGetListReqParams = {
  limit?: number;
  offset?: number;
  orders?: '-publishedAt' | 'publishedAt'; // 降順：-publishedAt 昇順：publishedA
  filters?: string;
};

export type MicroCmsImageItem = {
  url: string;
  height: string;
  width: string;
};
```

クエリパラメータを扱いやすくするように「query-string」を追加。

```bash
$ yarn add query-string
```

ブログ詳細取得のHTTPクライアントを実装。

```tsx:api/blog/_blogId.ts
import queryString from 'query-string';

import http from '@/utils/http/microcms';
import { MicroCmsItemGetRes, MicroCmsImageItem } from '@/types/microcms';

export type BlogSchema = {
  title: string;
  image: MicroCmsImageItem | null;
  content: string;
};

export type Blog = MicroCmsItemGetRes<BlogSchema>;

export type GetRes = Blog;

export type ReqParams = {
  fields?: string;
};

const get = (blogId: string, reqParams: ReqParams) => {
  const query = queryString.stringify(reqParams);
  return http.get(`blog/${blogId}?${query}`).json<GetRes>();
};

export { get };
```

ブログ一覧取得のHTTPクライアントを実装。

```tsx:api/blog/index.ts
import queryString from 'query-string';

import http from '@/utils/http/microcms';
import { MicroCmsGetListReqParams, MicroCmsListGetRes } from '@/types/microcms';
import { Blog } from './_blogId';

export type GetRes = MicroCmsListGetRes<Blog>;

export type ReqParams = MicroCmsGetListReqParams;

const get = (reqParams: ReqParams) => {
  const query = queryString.stringify(reqParams);
  return http.get(`blog?${query}`).json<GetRes>();
};

export { get };
```

APIクライアントをまとめておく。

```tsx:api/index.ts
import * as blog from '@/api/blog';
import * as blogId from '@/api/blog/_blogId';

export { blog, blogId };
```

### 4. ブログ詳細ページの実装

ライブラリを追加。

```bash
$ yarn add validator http-status-codes date-fns
$ yarn add -D @types/validator
```

パスを扱うutilsを追加。

```tsx:utils/pathUtils.ts
import validator from 'validator';

export const filterQueryValueToString = (queryValue?: string | string[]) =>
  typeof queryValue === 'string' ? queryValue : '';

export const filterQueryValueToInt = (queryValue?: string | string[]) => {
  if (typeof queryValue === 'string' && validator.isInt(queryValue)) {
    return parseInt(queryValue, 10);
  }
  return undefined;
};
```

日付を処理するutilsを追加。

```tsx:utils/dateUtils.ts
import { format } from 'date-fns';
import ja from 'date-fns/locale/ja';

export const formatJp = (date: Date, formatStr: string) =>
  format(date, formatStr, { locale: ja });

export const formatYMD = (date: Date) => format(date, 'yyyy.MM.dd');
```

レイアウトのコンポーネントを追加。
（使用しない場合は、飛ばしてOK）

```tsx:components/organisms/Layout.tsx
import React from 'react';

import Footer from './Footer';
import Header from './Header';

type Props = {};

const Layout: React.FC<Props> = ({ children }) => {
  return (
    <>
      <Header />
      <main>{children}</main>
      <Footer />
    </>
  );
};

export default Layout;
```

```tsx:components/organisms/Header.tsx
import React from 'react';

type Props = {};

const Header: React.FC<Props> = () => {
  return (
    <>
      <header>header</header>
    </>
  );
};

export default Header;
```

```tsx:components/organisms/Footer.tsx
import React from 'react';

type Props = {};

const Footer: React.FC<Props> = () => {
  return (
    <>
      <footer>footer</footer>
    </>
  );
};

export default Footer;
```

ブログ詳細の取得を実装。

```tsx:pages/blog/[blogId].tsx
import React from 'react';
import { GetStaticPropsContext, InferGetServerSidePropsType } from 'next';
import Error from 'next/error';
import { useRouter } from 'next/router';
import httpStatus from 'http-status-codes';

import * as api from '@/api';
import { Blog } from '@/api/blog/_blogId';

import Layout from '@/components/organisms/Layout';
import Main from '@/components/pages/blog/[blogId]/Main';

import * as pathUtils from '@/utils/pathUtils';

export const getStaticPaths = () => ({
  paths: [],
  fallback: true,
});

export const getStaticProps = async ({ params }: GetStaticPropsContext) => {
  const blogId = pathUtils.filterQueryValueToString(params?.blogId);
  let blog: Blog | null = null;
  try {
    blog = await api.blogId.get(blogId, {});
  } catch (error) {
    console.log(error);
  }
  return {
    props: {
      blog,
    },
    revalidate: 10,
  };
};

export default function Index({
  blog,
}: InferGetServerSidePropsType<typeof getStaticProps>) {
  const { isFallback } = useRouter();

  if (isFallback) return null;
  if (!blog) return <Error statusCode={httpStatus.NOT_FOUND} />;

  return (
    <>
      <Layout>
        <Main blog={blog} />
      </Layout>
    </>
  );
}
```

ブログ詳細画面の実装。

```tsx:components/pages/blog/[blogId]/Main.tsx
import React from 'react';

import { Blog } from '@/api/blog/_blogId';
import Content from './Content';

type Props = {
  blog: Blog;
};

const Main: React.FC<Props> = ({ blog }) => {
  return (
    <>
      <Content blog={blog} />
    </>
  );
};

export default Main;
```

```tsx:components/pages/blog/[blogId]/Content.tsx
import Image from 'next/image';
import React from 'react';
import { parseISO } from 'date-fns';

import * as dateUtils from '@/utils/dateUtils';
import { Blog } from '@/api/blog/_blogId';

type Props = {
  blog: Blog;
};

const Content: React.FC<Props> = ({
  blog: { title, content, image, publishedAt },
}) => (
  <>
    <div>
      {!!image && <Image src={image.url} width={100} height={100} />}
      <p>{dateUtils.formatYMD(parseISO(publishedAt))}</p>
      <p>{title}</p>
      <div dangerouslySetInnerHTML={{ __html: content }} />
    </div>
  </>
);

export default Content;
```

外部ドメインの画像をImageで扱えるように。

```jsx:next.config.js
module.exports = {
  images: {
    domains: ['images.microcms-assets.io'],
  },
};
```

### 5. ブログ一覧ページの実装

ページネーションライブラリの追加。

```bash
$ yarn add react-paginate
$ yarn add -D @types/react-paginate
```

一覧ページの表示数を定数に記載。

```tsx:constant/index.ts
export const BLOG_NUMBER_PER_PAGE = 4;
```

ブログ一覧の取得を実装。

```tsx:pages/blog/page/[page].tsx
import React from 'react';
import { GetStaticPropsContext, InferGetServerSidePropsType } from 'next';
import { useRouter } from 'next/router';

import Main from '@/components/pages/blog/Main';
import Layout from '@/components/organisms/Layout';

import * as api from '@/api';
import { Blog } from '@/api/blog/_blogId';

import * as pathUtils from '@/utils/pathUtils';
import { BLOG_NUMBER_PER_PAGE } from '@/constant';

export const getStaticPaths = () => ({
  paths: [],
  fallback: true,
});

export const getStaticProps = async ({ params }: GetStaticPropsContext) => {
  const page = pathUtils.filterQueryValueToInt(params?.page) || 1;
  let totalCount = 0;
  let blogs: Blog[] = [];
  try {
    const data = await api.blog.get({
      limit: BLOG_NUMBER_PER_PAGE,
      offset: BLOG_NUMBER_PER_PAGE * (page - 1),
      orders: '-publishedAt',
    });
    totalCount = data.totalCount;
    blogs = data.contents;
  } catch (error) {
    console.log(error);
  }
  return {
    props: {
      totalCount,
      blogs,
    },
    revalidate: 10,
  };
};

export default function Index({
  totalCount,
  blogs,
}: InferGetServerSidePropsType<typeof getStaticProps>) {
  const { isFallback } = useRouter();
  if (isFallback) return null;
  return (
    <Layout>
      <Main totalCount={totalCount} blogs={blogs} />
    </Layout>
  );
}
```

ブログ一覧の画面を追加。

```tsx:components/pages/blog/Main.tsx
import React from 'react';
import { useRouter } from 'next/router';

import BlogItem from './BlogItem';

import Paginate from '@/components/organisms/Paginate';

import { Blog } from '@/api/blog/_blogId';

import * as pathUtils from '@/utils/pathUtils';
import { BLOG_NUMBER_PER_PAGE } from '@/constant';

type Props = {
  totalCount: number;
  blogs: Blog[];
};

const Main: React.FC<Props> = ({ totalCount, blogs }) => {
  // router
  const { push, pathname, query } = useRouter();
  const page = pathUtils.filterQueryValueToInt(query.page) || 1;

  const handlePageChange = (selectedItem: { selected: number }) =>
    push({
      pathname,
      query: { ...query, page: selectedItem.selected + 1 },
    });

  return (
    <div>
      <div>
        {blogs.map((x) => (
          <BlogItem key={x.id} blog={x} />
        ))}
      </div>
      <Paginate
        forcePage={page - 1}
        pageCount={Math.ceil(totalCount / BLOG_NUMBER_PER_PAGE)}
        pageRangeDisplayed={10}
        marginPagesDisplayed={5}
        onPageChange={handlePageChange}
      />
    </div>
  );
};

export default Main;
```

```tsx:components/pages/blog/BlogItem.tsx
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { parseISO } from 'date-fns';

import { Blog } from '@/api/blog/_blogId';
import * as dateUtils from '@/utils/dateUtils';

type Props = {
  blog: Blog;
};

const BlogItem: React.FC<Props> = ({
  blog: { id, title, image, publishedAt },
}) => {
  const subTitle = dateUtils.formatYMD(parseISO(publishedAt));
  return (
    <Link href={`/blog/${id}`}>
      <div>
        {!!image && <Image src={image.url} width={100} height={100} />}
        <div>
          <p>{subTitle}</p>
          <p>{title}</p>
        </div>
      </div>
    </Link>
  );
};

export default BlogItem;
```

ページネーションを追加。

```tsx:components/organisms/Paginate.tsx
import React from 'react';
import ReactPaginate, { ReactPaginateProps } from 'react-paginate';

type Props = {} & ReactPaginateProps;

const Paginate: React.FC<Props> = (props) => (
  <>
    <ReactPaginate
      containerClassName="pagination"
      pageClassName="page-item"
      pageLinkClassName="page-link"
      activeClassName="active"
      previousLabel="<"
      nextLabel=">"
      previousClassName="page-item"
      nextClassName="page-item"
      previousLinkClassName="page-link"
      nextLinkClassName="page-link"
      disabledClassName="disabled"
      breakLabel="..."
      breakClassName="page-item"
      breakLinkClassName="page-link"
      {...props}
    />
  </>
);

export default Paginate;
```

ページネーションの見栄えが良くなるように、bootstrapのcssを追加。

```tsx:pages/_document.tsx
import React from 'react';
import Document, { Html, Head, Main, NextScript } from 'next/document';

class MyDocument extends Document {
  render() {
    return (
      <Html>
        <Head>
          <link rel="icon" href="/favicon.ico" />
          <link
            href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css"
            rel="stylesheet"
            integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1"
            crossOrigin="anonymous"
          />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

export default MyDocument;
```

1ページ目へのリダイレクトを追加

```tsx:pages/blog/index.tsx
import { GetServerSidePropsContext } from 'next';
import httpStatus from 'http-status-codes';

export const getServerSideProps = async ({
  res,
}: GetServerSidePropsContext) => {
  res.statusCode = httpStatus.MOVED_PERMANENTLY;
  res.setHeader('Location', '/blog/page/1');
  return {
    props: {},
  };
};

export default function Index() {
  return null;
}
```

```tsx:pages/blog/page/index.tsx
import { getServerSideProps } from '@/pages/blog';

export { getServerSideProps };

export default function Index() {
  return null;
}
```

## あとがき

このハンズオン後に、MicroCMSのJavaScript SDKが公式からリリースされました。
APIクライアント周りの実装は、こちらを使ってみても良いかと思います！

https://blog.microcms.io/introduce-sdk/