見出し画像

【2024年最新】Next.js 13 + microCMSでJamstackブログを構築するモダンWebアプリ開発

はじめに

この記事は、Next.js 13とmicroCMSを使用してJamstackブログを構築する手順について解説します。

Jamstackとは、JavaScript、API、およびMarkupの略であり、「高速なWebページを作成するためのアーキテクチャ」として注目されています。話題のNext.js 13フレームワークとコンテンツ管理システムのmicroCMSを使用し、ブログ開発を進めていきましょう。

具体的な手順に加えてコード例も多数掲載しておりますので初心者でも理解しやすく書かれた内容となっています。

対象読者は以下の通りです。

・簡単にブログアプリを作ってみたい方
・Next.js 13 から新たに追加されたApp Routerを学びたい方
・ヘッドレスCMSというモダンな技術を用いてブログサイトを構築したい方

簡単に作れる!ブログアプリの完成品

Jamstackブログのイメージ

こちらが完成品アプリのイメージです。

表示されているNext.jsやREST API、BigQueryの記事はmicroCMS側で設定したコンテンツです。

関連するタグ付けやそれぞれで色分けすることもできます。また、問い合わせ機能やコメント機能もモックで用意しています。

ハンズオンを通して、microCMSとNext.jsの連携方法やNext.jsアプリケーションの実装方法を学習していきましょう。

本記事で学べるモダン技術

ハンズオンで扱うモダン技術を3つ紹介します。

Next.jsとは

Next.jsは人気の高いReact ベースのフルスタックのJavaScriptフレームワークです。

ReactはJavaScript言語を用いた、Webサイト上のUIを構築するためのライブラリで、フレームワークとは、開発を効率化するための枠組みです。

Reactとは異なり、サーバー機能やURLルーティングの自動生成機能を持っているため、Next.js単体でWebアプリを動作させることや、初期化時に生成されたフォルダにファイルを配置するだけで簡単にURLを生成することができます。

Jamstackとは

Jamstackという概念は、2016年に誕生しました。アメリカでホスティングサービスを提供するNetlifyのCEO、Mathias Biilmannが「JAMStack」と定義をしたのが始まりです。

当初、Jamstackは、「JavaScript」「API」「Markup」の頭文字を取ってつけられたシステム構成(=Stack)を表していました。

現在はこの考え方が少し拡張され、定義上はJavaScriptとAPIは必須ではなくなりました。

ヘッドレスCMSとは

まずCMSとは、Contents Management Systemの略で、Webサイトのコンテンツを構成するテキストや画像、デザイン・レイアウト情報などを一元的に保存・管理するシステムのことです。

一方ヘッドレスCMSとは、従来のCMSでは存在していた「ヘッド(表示画面)」部分が分離されたバックエンドを扱うAPIベースのCMSのことです。

画面表示速度の速さや開発の自由度の高さなどで注目を集めています。

システムアーキテクチャ

アーキテクチャの例

こちらは、ヘッドレスCMS+Next.jsで構築するJamstackブログアプリによく見られるアーキテクチャです。

  1. コンテンツを投稿
    Operator(画像右上)がHeadless CMSに記事を投稿します。

  2. Webhook通知
    記事が投稿されると、そのタイミングでGitHubへ通知をします。

  3. Build & Deploy
    コンテンツ更新を通知されたGitHubはbuild processを実行します。コンテンツを生成したらVercelにデプロイします。

今回は、Next.jsとmicroCMSに範囲を絞って開発していきましょう。CI/CD周りの設定やデプロイ方法に関しては、関連記事を参考にしてください。

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

ここから実装していきましょう。(以下、コード全て公開)

まずは、Next.jsプロジェクトを作成します。

npx create-next-app@latest

Need to install the following packages:
  create-next-app@13.4.7
Ok to proceed? (y) y
✔ What is your project named? … my-jamstack
✔ Would you like to use TypeScript with this project? … No / Yes
✔ Would you like to use ESLint with this project? … No / Yes
✔ Would you like to use Tailwind CSS with this project? … No / Yes
✔ Would you like to use `src/` directory with this project? … No / Yes
✔ Use App Router (recommended)? … No / Yes
✔ Would you like to customize the default import alias? … No / Yes
Creating a new Next.js app in /.../.../my-jamstack.

続けて必要なパッケージをインストールしていきましょう。

  • Chakra UIの導入(UIライブラリー)

  • microCMS SDKの導入

  • HTMLパーサーの導入

npm install @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^6

npm install microcms-js-sdk

npm install html-react-parser

2. microCMSの設定

Next.jsから呼び出すために、先にmicroCMS側にコンテンツを登録しておきましょう。

アカウントの登録方法や基本的な操作方法はドキュメントをお読みください。

今回は、データスキーマを以下のようにセットしました。慣れてきたらスキーマをカスタマイズしてみましょう。

ブログ用のデータスキーマ
タグ用のデータスキーマ

ダッシュボード右上のAPIプレビューボタンを押すと、実際にAPIコールした時のレスポンスを確認することができます。

APIプレビュー画面

本文やサムネ画像は何でも構いません。各自で用意して設定してください。

ブログデータをサンプルで設定したら、続いてAPIキーやサービスドメインをNext.js側の環境変数ファイルに設定しましょう。

親ディレクトリ直下に".env.local"を作成してください。

// my-jamstack/.env.local
MICROCMS_SERVICE_DOMAIN=<--microCMSのサービスドメインを設定-->
MICROCMS_API_KEY=<--microCMSのAPIキーを設定-->

※サービスドメインとAPIキーの確認方法はこちらを参考にしてください。
https://blog.microcms.io/microcms-next-jamstack-blog/

3. Next.js各種ファイルの実装

srcディレクトリ配下の構造はこちらの通りです。少しファイルが多いですが、一つずつ確認していきましょう。

.
└── app
    ├── articles
    │   └── [slug]
    │       ├── ArticleContent.tsx
    │       ├── not-found.tsx
    │       └── page.tsx
    ├── common
    │   └── components
    │       └── index.tsx
    ├── libs
    │   └── microcms.ts
    ├── ArticleCard.tsx
    ├── ArticleList.tsx
    ├── Comments.tsx
    ├── error.tsx
    ├── favicon.ico
    ├── Footer.tsx
    ├── Header.tsx
    ├── layout.tsx
    ├── loading.tsx
    ├── Main.tsx
    ├── page.tsx
    ├── Profile.tsx
    ├── Provider.tsx
    ├── TagDetail.tsx
    └── Tags.tsx

コンテンツ取得用クライアントの作成

microCMS用のSDKを用いて、記事一覧を取得するメソッドや特定の記事を取得するメソッドを定義します。

// libs/microcms.ts

import { createClient } from "microcms-js-sdk";
import type {
 MicroCMSQueries,
 MicroCMSImage,
 MicroCMSDate,
} from "microcms-js-sdk";

//ブログの型定義
export type Blog = {
 id: string;
 title: string;
 description: string;
 summary: string;
 image?: MicroCMSImage;
 tag: Hashtag[];
} & MicroCMSDate;

export type Hashtag = {
 id: string;
 name: string;
 color: string;
} & MicroCMSDate;

if (!process.env.MICROCMS_SERVICE_DOMAIN) {
 throw new Error("MICROCMS_SERVICE_DOMAIN is required");
}

if (!process.env.MICROCMS_API_KEY) {
 throw new Error("MICROCMS_API_KEY is required");
}

// API取得用のクライアントを作成
export const client = createClient({
 serviceDomain: process.env.MICROCMS_SERVICE_DOMAIN,
 apiKey: process.env.MICROCMS_API_KEY,
});

// ブログ一覧を取得
export const getList = async (queries?: MicroCMSQueries) => {
 const listData = await client.getList<Blog>({
  endpoint: "blog",
  queries,
 });

 // データの取得が目視しやすいよう明示的に遅延効果を追加
 await new Promise((resolve) => setTimeout(resolve, 3000));

 return listData;
};

// ブログの詳細を取得
export const getDetail = async (
 contentId: string,
 queries?: MicroCMSQueries
) => {
 const detailData = await client.getListDetail<Blog>({
  endpoint: "blog",
  contentId,
  queries,
 });

 // データの取得が目視しやすいよう明示的に遅延効果を追加
 await new Promise((resolve) => setTimeout(resolve, 3000));

 return detailData;
};

メインページ

様々なコンポーネントをimportして、メインのページを作成します。先ほど定義したmicroCMSのメソッドを用いてコンテンツを取得しています。

/page.tsx
import ArticleList from "./ArticleList";
import { Tabs, Tab, TabList, TabPanels, TabPanel, Divider } from "./common/components";
import Comments from "./Comments"
import { getList } from "./libs/microcms"

export default async function Home() {
  const {contents} = await getList();

  return (
    <div>
      <Tabs variant='soft-rounded' colorScheme='blue'>
        <TabList>
          <Tab>記事一覧</Tab>
          <Tab>コメント</Tab>
        </TabList>
        <TabPanels>
          <TabPanel>
            <ArticleList contents={contents} />
          </TabPanel>
          <TabPanel>
            <Comments />
          </TabPanel>
        </TabPanels>
      </Tabs>
    </div>
  );
}

TabsやTab、Dividerなど各コンポーネントは、"./common/components"からimportしていますが、なぜでしょうか。

App Router内のコンポーネントはデフォルトで React Server Component として扱われるため、別コンポーネント内で"use client";を用いてClient Componentとして扱うようにします。

詳しくは以下リンクを参考にしてください。
(参考:https://zenn.dev/azukiazusa/articles/next-js-app-dir-tutorial

/common/components/index.tsx
"use client";
export * from "@chakra-ui/react";

続いて、記事リストを表示するためのArticleListコンポーネントを作成します。List内でArticleCardというコンポーネントを利用しています。

/ArticleList.tsx
import { VStack } from "./common/components";
import ArticleCard from "./ArticleCard";
import { Blog } from "./libs/microcms";

export default function ArticleList({ contents }: { contents: Blog[] }) {
  return (
    <VStack spacing={4} as="ul">
      {contents.map((content) => (
        <ArticleCard key={content.id} content={content} />
      ))}
    </VStack>
  );
}
/ArticleCard.tsx
import {
    Card,
    CardBody,
    Heading,
    Text,
    Image,
    Stack,
    Button
} from "./common/components";
import NextLink from "next/link";
import { Blog } from "./libs/microcms";
import Tags from "./Tags"

export default function ArticleCard({ content }: { content: Blog }) {
  return (
    <Card
      as={"li"}
      minW="100%"
      height="180px"
      direction={{ base: "column", sm: "row" }}
      overflow="hidden"
      variant="outline"
      borderRadius={20}
    >
      <Image
        objectFit='cover'
        maxW={{ base: '100%', sm: '250px' }}
        src={`${content.image?.url}`}
      />
      <Stack>
        <CardBody>
          <Heading size="md">{content.title}</Heading>
          <Text my="2">
            {content.description}
          </Text>
          <Tags tag={content.tag}/>
          <NextLink href={`/articles/${content.id}`}>
            <Button variant='solid' colorScheme='blue' my="3" size={"md"}>
              記事を読む
            </Button>
          </NextLink>
        </CardBody>
      </Stack>
    </Card>
  );
}

ArticleCardコンポーネントでは、タグ付けのためのコンポーネントをimportしています。そちらも作成しましょう。

/Tags.tsx
import {
    HStack
} from "./common/components";
import { Hashtag } from "./libs/microcms";
import TagDetail from "./TagDetail"

const Tags = ({ tag }: { tag : Hashtag[] }) => {
  return (
    <HStack spacing={2}>
      {tag.map((tag) => (
        <TagDetail key={tag.id} tag={tag} />
      ))}
    </HStack>
  );
}

export default Tags;
/TagDetail.tsx
import {
    Tag
} from "./common/components";
import { Hashtag } from "./libs/microcms";

export default function TagDetail({ tag }: { tag : Hashtag }) {
  return (
    <Tag size={"sm"} variant='solid' color={"white"} bgColor={tag.color}>
      {tag.name}
    </Tag>
  );
}

さらに、Commentsコンポーネントを作成していきましょう。実際のデータはモックとしてcomments.jsonファイルからimportしています。

/Comments.tsx
import { Stack, Card, CardHeader, Heading, CardBody, Text } from "./common/components";
import { comments } from "../../comments.json"

const Comments = () => {
  return (
    <div>
      <Stack spacing='4'>
        {comments.map((comment) => (
          <Card 
            key={comment.id} 
            variant="filled"
            borderRadius={20}
          >
            <CardHeader>
              <Heading size='md'> {comment.title}</Heading>
            </CardHeader>
            <CardBody>
              <Text>{comment.body}</Text>
            </CardBody>
          </Card>
        ))}
      </Stack>
    </div>
  );
}

export default Comments;
/comments.json
{
    "comments": [
      {
        "id": 1,
        "title": "すごく良かった",
        "body": "1つ目のコメントです。",
        "createdAt": "2019-01-01T00:00:00.000Z",
        "updatedAt": "2019-01-01T00:00:00.000Z"
      },
      {
        "id": 2,
        "title": "普通",
        "body": "2つ目のコメントです。",
        "createdAt": "2019-01-01T00:00:00.000Z",
        "updatedAt": "2019-01-01T00:00:00.000Z"
      },
      {
        "id": 3,
        "title": "良かった",
        "body": "3つ目のコメントです。",
        "createdAt": "2019-01-01T00:00:00.000Z",
        "updatedAt": "2019-01-01T00:00:00.000Z"
      },
      {
        "id": 4,
        "title": "いまいち",
        "body": "comment 4",
        "createdAt": "2019-01-01T00:00:00.000Z",
        "updatedAt": "2019-01-01T00:00:00.000Z"
      }
    ]
  }

記事詳細ページ

記事詳細のページは、app/articles/[slug]配下に用意します。

/articles/[slug]/page.tsx
import ArticleContent from "./ArticleContent";
import { getDetail } from "../../libs/microcms"

export default async function ArticleDetail({
  params,
}: {
  params: { slug: string };
}) {
  const contentId = params.slug;
  const contentPromise = getDetail(contentId);
  const content = await contentPromise;

  return (
    <div>
      <ArticleContent content={content} />
    </div>
  );
}

記事詳細ページでは、受け取ったHTMLをパースするための"html-react-parser"をimportしています。

/articles/[slug]/ArticleContent.tsx
import {
  Card,
  CardHeader,
  CardBody,
  Heading,
} from "../../common/components";
import { Blog } from "../../libs/microcms"
import parse from "html-react-parser";

export default function ArticleContent({ content }: { content: Blog }) {
  return (
    <Card as="article">
      <CardHeader>
        <Heading as="h1">{content.title}</Heading>
      </CardHeader>
      <CardBody>
          {parse(content.summary)}
      </CardBody>
    </Card>
  );
}

not-found.tsxは、ページが見つからない場合の UI を定義するファイルです。

/articles/[slug]/not-found.tsx
import { Heading, Button } from "../../common/components";
import NextLink from "next/link";

export default function NotFound() {
  return (
    <div>
      <Heading mb={4}>お探しの記事が見つかりませんでした。</Heading>
      <Button as={NextLink} href="/">
        トップへ戻る
      </Button>
    </div>
  );
}

レイアウト

以下は、レイアウトを定義するファイルです。 複数のページで共通のヘッダーやフッター、ナビゲーションなどを指定できます。

/layout.tsx
import Provider from "./Provider";
import Header from "./Header";
import Main from "./Main";
import Footer from "./Footer";
import Profile from "./Profile"

export const metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
  themeColor: "#ffffff",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <head />
      <body>
        <Provider>
          <Header />
          <Profile />
          <Main>{children}</Main>
          <Footer />
        </Provider>
      </body>
    </html>
  );
}

Chakra UI は内部で useState を使用しているのでServer Component として扱うことができません。そのため、そのまま使うとコンパイルエラーが発生します。

この問題を解決するために、Chakra UIのコンポーネントをServer Componentとして扱うのではなく、Client Component として扱うように設定する必要があります。

/Provider.tsx
"use client";

import { ChakraProvider } from "@chakra-ui/react";

export default function Provider({ children }: { children: React.ReactNode }) {
  return <ChakraProvider>{children}</ChakraProvider>;
}

さて各ページに共通するヘッダー、フッダーを定義していきましょう。

/Header.tsx
import { Box, Flex, Heading, Button } from "./common/components";
import NextLink from "next/link";

/**
 * ヘッダーコンポーネント
 */
export default function Header() {
  return (
    <Box as="header">
      <Flex
        bg="blue.700"
        color="white"
        minH={"60px"}
        py={{ base: 2 }}
        px={{ base: 4 }}
        borderBottom={1}
        borderStyle="solid"
        borderColor="gray.200"
        align="center"
      >
        <Flex flex={1} justify="space-between" maxW="5xl" mx="auto">
          <Heading as="h1" size="lg">
            <NextLink href="/">My Jamstack Blog</NextLink>
          </Heading>
          <Button
            fontSize="sm"
            fontWeight={600}
            colorScheme="white"
            variant="outline"
          >
            問い合わせ
          </Button>
        </Flex>
      </Flex>
    </Box>
  );
}
/Footer.tsx
import { Container, Box, Text } from "./common/components";

export default function Footer() {
  return (
    <Box bg="gray.50" color="gray.700" as="footer">
      <Container maxW="5xl" py={4}>
        <Text as="small"2023 sample</Text>
      </Container>
    </Box>
  );
}

メインページを表示するためのコンテナを用意します。ページの最小高さや最大幅を定義しています。

/Main.tsx
import { Container } from "./common/components";

export default function Main({ children }: { children: React.ReactNode }) {
  return (
    <Container
      as="main"
      maxW="container.lg"
      my="4"
      minH="calc(100vh - 115px - 2rem)"
    >
      {children}
    </Container>
  );
}

プロフィール画像と名前、役割を書いたコンポーネントも用意しましょう。

/Profile.tsx
import { 
  Box, 
  Divider, 
  Text, 
  WrapItem, 
  Avatar, 
  HStack, 
  VStack, 
  AbsoluteCenter
 } from "./common/components";

/**
 * ヘッダーコンポーネント
 */
export default function Profile() {
  return (
    <div>
      <Box
        as="header"
        height={"200px"}
        position='relative'
      >
        <AbsoluteCenter  color='black' axis='both'>
          <HStack spacing='24px'>
            <WrapItem>
              <Avatar name='Dan Abrahmov' src="https://bit.ly/ryan-florence" size="xl"/>
            </WrapItem>
            <VStack>
              <Text fontSize="xl">
                Ryan Florence
              </Text>
              <Text fontSize="xs">
                フルスタックエンジニア
              </Text>
            </VStack>
          </HStack>
        </AbsoluteCenter>
      </Box>
      <Divider />
    </div>

  );
}

エラーファイル

Errorコンポーネントは、Client componentsとして定義する必要があります。エラー発生時に画面に表示する内容を記述しましょう。

/error.tsx
"use client";

import { useEffect } from "react";
import { Heading, Button } from "./common/components";

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  useEffect(() => {
    console.error(error);
  }, [error]);

  return (
    <div>
      <Heading mb={4}>予期せぬエラーが発生しました。</Heading>
      <Button onClick={() => reset()}>Try again</Button>
    </div>
  );
}

ローディングファイル

ローディングUIを定義するファイルです。データの読み込み中やページの切り替え時に表示されるローディングUIを定義するために使用されます。

通常、非同期データの取得や処理が行われている間に表示されるコンポーネントとして使われることが多いでしょう。

/loading.tsx
import { Box, Spinner } from "./common/components";

export default function Loading() {
  return (
    <Box justifyContent="center" display="flex">
      <Spinner color="orange.400" size="xl" />
    </Box>
  );
}

4. 動作確認

ローカルで起動

以下、コマンドでlocalhostに開発したWebアプリを起動します。
※デフォルトは、http://localhost:3000です。お手元のブラウザで動きを確認してみましょう。

npm run dev

アプリケーションが起動しないなど、不具合ございましたら、ご連絡ください。

5. 関連記事


この記事が気に入ったらサポートをしてみませんか?