22
24

More than 3 years have passed since last update.

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

Posted at

まえがき

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

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

Next.jsとは

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

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

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

Next.jsを学ぶには

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

MicroCMSとは

ヘッドレスCMSとは

その他のヘッドレスCMS(一例)

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

基本編

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

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

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

2. MicroCMSの用意

Screenshot_from_2021-04-14_13-26-06.png

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

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

$ yarn add ky@0.25.1 ky-universal@0.8.2

環境変数を追加。

MICRO_CMS_API_ENDPOINT=xxxxx
MICRO_CMS_API_KEY=xxxxx

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

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

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

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の共通レスポンスの型を定義。

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」を追加。

$ yarn add query-string

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

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クライアントを実装。

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クライアントをまとめておく。

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

export { blog, blogId };

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

ライブラリを追加。

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

パスを扱うutilsを追加。

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を追加。

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)

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;
components/organisms/Header.tsx
import React from 'react';

type Props = {};

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

export default Header;
components/organisms/Footer.tsx
import React from 'react';

type Props = {};

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

export default Footer;

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

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>
    </>
  );
}

ブログ詳細画面の実装。

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;
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で扱えるように。

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

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

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

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

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

constant/index.ts
export const BLOG_NUMBER_PER_PAGE = 4;

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

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>
  );
}

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

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;
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;

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

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を追加。

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ページ目へのリダイレクトを追加

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;
}
pages/blog/page/index.tsx
import { getServerSideProps } from '@/pages/blog';

export { getServerSideProps };

export default function Index() {
  return null;
}

あとがき

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

22
24
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
24