まえがき
以下の勉強会で使用したハンズオン資料を再編集したものです。
- 【ハンズオン】Next.jsとmicroCMSでブログサイトを作ろう (2021/04/14 19:30〜)
- 【ハンズオン】Next.jsとmicroCMSでブログサイトを作ろう|IT勉強会ならTECH PLAY[テックプレイ]
【ハンズオン】Next.jsとmicroCMSでブログサイトを作ろう!
Next.jsとは
Reactを本番環境で利用するためのフレームワーク
- 本番環境に必要な設定が予め組み込まれている
- Webpack、TypeScript、多言語対応、画像の最適化、サーバーサイド、静的生成、SEOなど
- 環境構築に要する時間を短縮できる
- Vercelと連携して爆速でデプロイできる
- Next.jsのデプロイに最適化されたホスティングサービス
- もちろん他のクラウドやホスティングサービスでもデプロイ可能
- Firebaseの例
Next.jsを学ぶには
- Reactの知識が前提
- 公式のチュートリアル
※ こちらの記事で、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の用意
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
環境変数の型定義を追加。
declare namespace NodeJS {
interface ProcessEnv {
// サーバーのみで扱う環境変数
readonly MICRO_CMS_API_ENDPOINT: string;
readonly MICRO_CMS_API_KEY: string;
}
}
APIリクエスト時の共通処理を追加。
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の共通レスポンスの型を定義。
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クライアントを実装。
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クライアントを実装。
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クライアントをまとめておく。
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を追加。
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を追加。
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)
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;
import React from 'react';
type Props = {};
const Header: React.FC<Props> = () => {
return (
<>
<header>header</header>
</>
);
};
export default Header;
import React from 'react';
type Props = {};
const Footer: React.FC<Props> = () => {
return (
<>
<footer>footer</footer>
</>
);
};
export default Footer;
ブログ詳細の取得を実装。
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>
</>
);
}
ブログ詳細画面の実装。
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;
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で扱えるように。
module.exports = {
images: {
domains: ['images.microcms-assets.io'],
},
};
5. ブログ一覧ページの実装
ページネーションライブラリの追加。
$ yarn add react-paginate
$ yarn add -D @types/react-paginate
一覧ページの表示数を定数に記載。
export const BLOG_NUMBER_PER_PAGE = 4;
ブログ一覧の取得を実装。
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>
);
}
ブログ一覧の画面を追加。
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;
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;
ページネーションを追加。
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を追加。
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ページ目へのリダイレクトを追加
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;
}
import { getServerSideProps } from '@/pages/blog';
export { getServerSideProps };
export default function Index() {
return null;
}
あとがき
このハンズオン後に、MicroCMSのJavaScript SDKが公式からリリースされました。
APIクライアント周りの実装は、こちらを使ってみても良いかと思います!