Next.js (app router)とmicroCMSの連携して簡易ブログを作ってみる
Next.js (app router)とmicroCMSの連携して簡易ブログを作ってみる
app routerについて少しでも慣れるためmicroCMSを連携したブログを実装してみる。
SEO関係のタグは一切ついてないので今後学習も兼ねて付与していく。
デモサイトURL
Githubリポジトリ
実際の画面
トップページ
投稿一覧ページ
記事詳細ページ
検索結果ページ
App Routerについてのメモ
ディレクトリ構成
ディレクトリ構成について簡単に調べる。公式はある程度自由にやっていいらしいと言っている。
プライベートフォルダ
フォルダ名の先頭に_をつけるとルーティングされない。
├ app
└ _components // 配下はすべて無視される
├ home
ルートグループ
()で囲んだディレクトリのこと。そのディレクトリ名はURL名に含まれないが、その配下のルーティングは適用されるので、ルーティングに影響を与えずグループ化することができる。
機能ごとに分類したり、ログインが必須、未ログインでも使用できるルーティングみたいに分類できるのでディレクトリ構成を理解しやすくなる。
├ app
└ (private) // ここはURLに含まれない
├ dashboard // /dashboardになる
└ page.tsx
ページ
appディレクトリの下にフォルダを作成し、page.tsxを作成する。そうすると/フォルダ名/
というルーティングが作成される。pages routerのルーティングとそこまで変化を感じないのでこれは良い。
レイアウト
appの直下にlayout.tsxを作成すると全ページに適応される共通レイアウトとなる。html
, body
タグが必須。ルートレイアウトを継承させないためにはルートグループでグループ分けし、その中にlayout.tsxを作る。なおhtml
, body
タグが必須
技術スタック
今回のブログの使用する主な技術スタックは以下の通り
- Next.js (app router)
- Vercel
- Tailwind CSS
- microCMS
設計編
ディレクトリ構造
├ app
├ search
├ page.tsx
├ posts
├ [postId]
├ page.tsx
├ page.tsx
├ page.tsx
├ layout.tsx
├ loading.tsx
├ hooks
└ use-theme.tsx
├ components
├ layout
├ header.tsx
├ footer.tsx
├ posts
├ card.tsx
├ toc.tsx
├ form
├ search.tsx
├ lib
├ react-markdown.tsx
├ theme
├ trigger.tsx
└ provider.tsx
├ libs
└ client.ts
├ utils
└ cn.ts
├ types
└ post.ts
ブログの機能一覧
主な機能は以下の通りで、必要最低限なブログの機能のみ実装する
- 記事一覧を表示
- 記事の詳細情報を表示
- 検索
ページのURL設計
URLは以下のようにする。
URL | 説明 |
---|---|
/ | 最新記事のピックアップページ |
/posts | 記事一覧ページ |
/posts/:id | 記事のコンテンツページ |
実装編
Next.jsプロジェクトの作成
npx create-next-app .
// 選択内容
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like your code inside a `src/` directory? … No
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to use Turbopack for next dev? … No
✔ Would you like to customize the import alias (@/* by default)? › Yes
UIライブラリのインストール
今回はMedusa UIを使用する。
インストールの手順は以下を参考
アイコンライブラリのインストール
npm install @medusajs/icons --save
使用するフォント
タイトルにはruwudu
, 日本語はNoto Sans JP
、英字にはGeist Sans
を使用する。
Geistフォントのインストール
npm i geist
@tailsindcss/typographyのインストール
npm i -D @tailwindcss/typography
tailwind.config.ts
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
presets: [require("@medusajs/ui-preset")],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./node_modules/@medusajs/ui/dist/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
fontFamily: {
"noto-sans": ['var(--font-noto-sans)'],
}
},
},
plugins: [
require("@tailwindcss/typography")
],
} satisfies Config;
使用する全ライブラリ一覧
npm i @medusajs/icons @medusajs/ui geist lenis microcms-js-sdk next-themes react-markdown react-syntax-highlighter rehype-raw rehype-slug remark-breaks remark-gfm --save
npm i -D @medusajs/ui-preset @tailwindcss/typography @types/react-syntax-highlighter
ダークモードの実装
以下のサイトの通りにやる。
注意点
- bodyタグ内にThemeProviderを配置する
- htmlタグにsuppressHydrationWarningを付与しておくこと
ファイルはこんな感じになる。
プロバイダー
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
テーマ用のカスタムフック
"use client"
import { useTheme as useNextTheme } from "next-themes";
export const useTheme = () => {
const { theme, setTheme } = useNextTheme();
const setLight = () => {
setTheme("light");
};
const setDark = () => {
setTheme("dark");
};
const setSystem = () => {
setTheme("system");
};
return {
theme,
setTheme,
setLight,
setDark,
setSystem,
};
};
テーマトリガー
'use client';
import { useTheme } from "@/hooks/use-theme";
import { ComputerDesktop, Moon, Sun } from "@medusajs/icons";
import { DropdownMenu } from "@medusajs/ui"
import { useEffect, useState } from "react";
export const ThemeTrigger = () => {
const [isLoading, setLoading] = useState<boolean>(false)
const { theme, setLight, setDark, setSystem } = useTheme();
useEffect(() => {
setLoading(true)
}, [])
if (!isLoading) return <></>
return (
<DropdownMenu>
<DropdownMenu.Trigger
className="focus:outline-none"
>
{theme === "light" && <Sun />}
{theme === "dark" && <Moon />}
{theme === "system" && <ComputerDesktop />}
</DropdownMenu.Trigger>
<DropdownMenu.Content className="min-w-10 gap-2 flex flex-col">
<DropdownMenu.Item
onClick={() => setLight()}
>
<Sun />
</DropdownMenu.Item>
<DropdownMenu.Item
onClick={() => setDark()}
>
<Moon />
</DropdownMenu.Item>
<DropdownMenu.Item
onClick={() => setSystem()}
>
<ComputerDesktop />
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)
}
この時点での画面
共通レイアウトの実装
Headerコンポーネント
"use client";
import Link from "next/link";
import {
Ruwudu
} from "next/font/google"
import { cn } from "@/utils/cn";
import { ThemeTrigger } from "@/components/theme/trigger";
const ruwudu = Ruwudu({
weight: ["400", "500"],
subsets: ["latin"]
})
export const Header = () => {
return (
<header className="w-full font-noto-sans absolute top-0">
<div className="w-full h-20 px-8 md:w-4/5 mx-auto flex items-center gap-8">
<h1 className={cn(
"text-3xl leading-0 mr-auto",
ruwudu.className
)}>
<Link
href={"/"}
>
Blog
</Link>
</h1>
<nav>
<ul>
<li>
<Link
href={"/posts"}
className="hover:text-gray-500 dark:hover:text-gray-800 transition-all text-sm"
>
記事一覧
</Link>
</li>
</ul>
</nav>
<ThemeTrigger />
</div>
</header>
)
}
Footerコンポーネント
"use client";
import { cn } from "@/utils/cn"
import { Ruwudu } from "next/font/google"
import Link from "next/link"
const ruwudu = Ruwudu({
weight: ["400", "500"],
subsets: ["latin"]
})
export const Footer = () => {
return (
<footer className="w-full font-noto-sans border-t bg-gray-700">
<div className="w-full h-12 px-8 md:w-4/5 mx-auto flex items-center gap-8">
<h1>
<Link
href={"/"}
className={cn(
"text-base leading-1 mr-auto text-gray-50",
ruwudu.className
)}
>
Blog
</Link>
</h1>
</div>
</footer>
)
}
appディレクトリ直下のlayout.tsxを編集する。
編集する前にインストールする。lenisはスムーズスクロールを簡単に実現できるライブラリ。
npm i lenis
import "@/app/globals.css"
import { Footer } from "@/components/layout/footer";
import { Header } from "@/components/layout/header";
import { ThemeProvider } from "@/components/theme/provider";
import { cn } from "@/utils/cn";
import { Noto_Sans_JP } from "next/font/google";
import { GeistSans } from "geist/font/sans";
import ReactLenis from "lenis/react";
interface Props {
children: React.ReactNode;
}
interface Props {
children: React.ReactNode;
}
const NotoSansJP = Noto_Sans_JP({
weight: ["400", "500", "600", "700"],
subsets: ["latin"],
variable: "--font-noto-sans",
});
const Layout = ({ children }: Props) => {
return (
<html lang="ja" suppressHydrationWarning>
<body
className={cn(
GeistSans.className,
NotoSansJP.variable,
"select-none"
)}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<ReactLenis
root
options={{ lerp: 0.1, duration: 2 }}
>
<Header />
<main>
{children}
</main>
<Footer />
</ReactLenis>
</ThemeProvider>
</body>
</html>
)
}
export default Layout
page.tsxに適当にテキストを表示するとこうなる。
microCMSとの連携
app routerを使用しているので以下のドキュメントを参考にする。
サムネイル画像を設定したいので、以下のサイトを参考にしてAPIスキーマを作成する。
今回のブログのAPIスキーマをmicroCMSで作る。
ブログのAPIスキーマ
カテゴリのAPIスキーマ
テスト用データを12個ほど作成する。
型定義ファイルを作成する。
import { MicroCMSDate, MicroCMSImage } from "microcms-js-sdk";
export interface ICategory extends MicroCMSDate {
id: string;
title: string;
slug: string;
}
export interface IPost extends MicroCMSDate {
id: string;
title: string;
description: string;
content: string;
image: MicroCMSImage;
categories: ICategory[]
}
import { IPost } from '@/types/post';
import { createClient, MicroCMSQueries } from 'microcms-js-sdk';
export const client = createClient({
serviceDomain: process.env.MICROCMS_SERVICE_DOMAIN || '',
apiKey: process.env.MICROCMS_API_KEY || '',
});
export const getPosts = async (queries?: MicroCMSQueries) => {
const posts = await client.getList<IPost>({
endpoint: "posts",
queries
})
await new Promise((resolve) => setTimeout(resolve, 2000));
return posts
}
トップページの実装
特に特別な実装は何もしない。データを取得してmapを使って表示する。
投稿カードコンポーネントの作成
import { IPost } from "@/types/post"
import { Badge, Container } from "@medusajs/ui"
import Image from "next/image"
import Link from "next/link"
interface Props {
post: IPost
}
export const PostCard = ({ post }: Props) => {
return (
<Link
href={`/posts/${post.id}`}
>
<Container
className="p-0 border-0 shadow-none flex flex-col gap-3 dark:bg-transparent"
>
<Image
src={post.image.url}
alt="記事名の画像"
width={1200}
height={480}
className="object-cover rounded-lg"
/>
<h4 className="text-lg font-semibold">{post.title}</h4>
<p className="text-sm text-gray-500">{post.description}</p>
<div className="mb-3 flex flex-wrap items-center gap-4">
{post.categories.map((category) => (
<Badge rounded="full" key={category.id}>{category.title}</Badge>
))}
</div>
<span className="text-right text-xs text-gray-500 leading-none font-sans">{new Date(post.createdAt).toLocaleDateString('sv-SE').replaceAll("-", "/")}</span>
</Container>
</Link>
)
}
ローディングコンポーネントの作成
loading.tsxを作成するとpage.tsxが表示されるまでのローディング画面が表示される。
const Loading = () => {
return (
<div className="w-screen h-screen fixed flex justify-center items-center bg-white z-[100]">
<svg
xmlns="http://www.w3.org/2000/svg" width="24"
height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round" className="size-12 lucide lucide-loader-circle animate-spin text-teal-500">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
</div>
)
}
export default Loading;
import { SearchForm } from "@/components/form/search";
import { PostCard } from "@/components/posts/card";
import { getPosts } from "@/libs/client"
import { Button } from "@medusajs/ui";
import Link from "next/link";
const Top = async () => {
const { contents } = await getPosts({
limit: 6
});
return (
<div className="w-full px-8 pt-32 py-8 md:w-4/5 mx-auto font-noto-sans">
<h1 className="mb-8 scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
僕たちしっかりやろうねえジョバンニ
</h1>
<p className="mb-16 text-center text-lg text-slate-500">にわかに、車のなかが、ぱっとあかりが射して来ました。ですからもしもこの天の川がほんとうに川だと</p>
<SearchForm />
<div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
>
<div className="cols-span-1 md:col-span-2 lg:col-span-3">
<h3 className="scroll-m-20 text-xl font-semibold tracking-tight">
人気の投稿
</h3>
</div>
{contents.map((content) => (
<PostCard
post={content}
key={content.id}
/>
))}
<div className="flex justify-center cols-span-1 md:col-span-2 lg:col-span-3">
<Button
variant="secondary"
size="large"
className="px-6 rounded-full"
asChild
>
<Link
href={"/posts"}
>
全記事一覧
</Link>
</Button>
</div>
</div>
</div>
)
}
export default Top
検索フォームは検索ページにクエリパラメータで検索ワードを付与した状態で遷移する。そのページでデータの検索を行うため。
"use client";
import { Input } from "@medusajs/ui"
import { useRouter } from "next/navigation";
import { FormEvent, useRef } from "react";
export const SearchForm = () => {
const router = useRouter();
const ref = useRef<HTMLInputElement>(null)
const onSearch = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
router.push(`/search?search=${ref.current?.value}`)
}
return (
<form
onSubmit={onSearch}
className="w-full md:w-3/5 lg:w-2/5 mb-16 mx-auto"
>
<Input
type="search"
ref={ref}
/>
</form>
)
}
トップページ
記事一覧ページの実装
ここも特に特別な実装は何もしない。データを取得してmapを使って表示する。
import { PostCard } from "@/components/posts/card";
import { getPosts } from "@/libs/client";
const AllPost = async () => {
const { contents } = await getPosts({
limit: 12
});
return (
<div className="w-full px-8 py-8 pt-24 md:pt-32 md:w-4/5 mx-auto font-noto-sans">
<div
className="mb-8 lg:mb-12 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 font-noto-sans"
>
<div className="cols-span-1 md:col-span-2 lg:col-span-3">
<h3 className="scroll-m-20 text-xl font-semibold tracking-tight">
投稿一覧
</h3>
</div>
{contents.map((content) => (
<PostCard
post={content}
key={content.id}
/>
))}
</div>
</div>
)
}
export default AllPost;
投稿一覧ページ
記事の詳細ページの実装
"use client"
import { IBlock } from "@/types/block";
import { useLenis } from "lenis/react";
import { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown"
import rehypeRaw from "rehype-raw";
import rehypeSlug from "rehype-slug";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import { Toc } from "@/components/posts/toc";
import { cn } from "@/utils/cn";
import {Prism as SyntaxHighlighter} from 'react-syntax-highlighter'
import {vscDarkPlus} from 'react-syntax-highlighter/dist/esm/styles/prism'
interface Props {
content: string;
}
export const Markdown = ({ content }: Props) => {
const contentRef = useRef<HTMLDivElement>(null);
const [blocks, setBlocks] = useState<IBlock[]>([]);
const [y, setY] = useState<number>(0)
const [direction, setDirection] = useState<1 | -1 | 0>(0);
useLenis(({ scroll, direction }) => {
setY(Math.ceil(scroll))
setDirection(direction)
})
useEffect(() => {
if (contentRef.current) {
const headings = contentRef.current.querySelectorAll('h2');
const arrs: IBlock[] = []
headings.forEach((heading, i) => {
arrs.push({
index: i,
id: heading.id,
offsetTop: heading.offsetTop,
text: heading.textContent || ""
});
});
setBlocks([ ...blocks, ...arrs ]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [content]);
return (
<div
className="w-full mb-12 relative"
ref={contentRef}
>
<Toc
blocks={blocks}
direction={direction}
y={y}
/>
<ReactMarkdown
components={{
code(props) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {children, className, node, ref, ...rest} = props
const match = /language-(\w+)/.exec(className || '')
return match ? (
<SyntaxHighlighter
{...rest}
PreTag="div"
language={match[1]}
style={vscDarkPlus}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code {...rest} className={className}>
{children}
</code>
)
}
}}
remarkPlugins={[
remarkGfm,
remarkBreaks,
]}
rehypePlugins={[
rehypeSlug,
rehypeRaw
]}
className={cn(
"prose prose-base lg:prose-xl dark:prose-invert max-w-none w-full md:w-8/12 prose-pre:bg-transparent prose-pre:p-0 prose-pre:m-0 prose-code:bg-transparent prose-code:p-0 prose-code:m-0",
!blocks.length && "md:w-full"
)}
>
{content}
</ReactMarkdown>
</div>
)
}
最初はスクロールするたびに目次が追従するが、ある一定の高さを超えると追従をやめる。それを実現しているのがtop: 196 - y > 60 ? 196 - y : 60
。
useEffect
内でh2タグを通過するたびに何個目かをカウントしてstateに保管している。directionはlenisの値を渡している。1が下向きにスクロール、-1が上向きにスクロール。
"use client"
import { IBlock } from "@/types/block"
import { cn } from "@/utils/cn"
import Link from "next/link"
import { useEffect, useState } from "react"
export const Toc = ({ blocks, y, direction }: {blocks: IBlock[], y: number, direction: number, }) => {
const [current, setCurrent] = useState(0);
useEffect(() => {
if (blocks.length === 1) {
setCurrent(0)
return;
}
const prev = blocks[current - 1] && blocks[current - 1]
const next = blocks[current + 1] && blocks[current + 1]
if (direction === 1) {
if (next && y > next.offsetTop) {
setCurrent(current + 1)
}
} else if (direction === -1) {
if (prev && y <= prev.offsetTop) {
setCurrent(current - 1);
}
}
}, [y]);
if (!blocks.length) return <></>
return (
<nav
className="fixed hidden md:block md:min-w-36 lg:min-w-64 right-[calc(2rem+10%)] top-[196px]"
style={{
top: 196 - y > 60 ? 196 - y : 60
}}
>
<h3 className="mb-2 scroll-m-20 text-xl font-semibold tracking-tight">目次</h3>
<ol
className={cn(
"pl-4 text-sm",
)}
>
{blocks.map((block, i) => (
<li
className={cn(
"py-1 text-sm text-gray-400 dark:text-gray-500 hover:text-gray-800 transition-all list-disc font-semibold",
i === current && "text-gray-800 dark:text-gray-200 marker:text-teal-500"
)}
key={`${block.text}-${i}`}
>
<Link
href={`#${block.id}`}
>
{block.text}
</Link>
</li>
))}
</ol>
</nav>
)
}
import { TriangleRightMini } from "@medusajs/icons";
import Link from "next/link";
import { Badge, } from "@medusajs/ui";
import { getPost } from "@/libs/client";
import { Markdown } from "@/components/lib/react-markdown";
const PostDetail = async ({
params
}: {
params: { postId: string };
}) => {
const postId = await params.postId
const post = await getPost(postId)
return (
<div className="w-full px-8 py-8 pt-24 md:pt-32 md:w-4/5 mx-auto font-noto-sans">
<div
className="mb-8 font-noto-sans"
>
<ol className="flex items-center gap-1 text-sm text-gray-500">
<li className="hover:text-gray-800 transition-all">
<Link
href={"/"}
>
/
</Link>
</li>
<li>
<TriangleRightMini />
</li>
<li className="hover:text-gray-800 transition-all">
<Link
href={"/posts"}
>
記事一覧
</Link>
</li>
<li>
<TriangleRightMini />
</li>
<li>
{post.title}
</li>
</ol>
</div>
<div className="mb-8 lg:mb-12 flex flex-wrap items-center gap-4">
{post.categories.map((category) => (
<Badge rounded="full" key={category.id}>{category.title}</Badge>
))}
</div>
<Markdown content={post.content} />
</div>
)
}
export default PostDetail;
記事の詳細ページ
検索結果ページの実装
import { SearchForm } from "@/components/form/search";
import { PostCard } from "@/components/posts/card";
import { getPosts } from "@/libs/client"
const Search = async ({
searchParams
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) => {
const search = (await searchParams).search
const { contents } = await getPosts({
q: search?.toString(),
limit: 12
});
return (
<div className="w-full px-8 pt-32 py-8 md:w-4/5 mx-auto font-noto-sans">
<h1 className="mb-8 scroll-m-20 text-center text-4xl font-extrabold tracking-tight lg:text-5xl">
僕たちしっかりやろうねえジョバンニ
</h1>
<p className="mb-16 text-center text-lg text-slate-500">にわかに、車のなかが、ぱっとあかりが射して来ました。ですからもしもこの天の川がほんとうに川だと</p>
<SearchForm />
<div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
>
<div className="cols-span-1 md:col-span-2 lg:col-span-3">
<h3 className="scroll-m-20 text-xl font-semibold tracking-tight">
検索結果
</h3>
</div>
{!contents.length && (
<div>
<h3>見つかりませんでした。</h3>
</div>
)}
{contents.map((content) => (
<PostCard
post={content}
key={content.id}
/>
))}
</div>
</div>
)
}
export default Search
検索結果ページ
感想
ディレクトリ構成が自由度が高いゆえにフォルダの切り方やコンポーネントの配置などの分割が難しく感じる。設計面や保守面を考えた設計を今後学べていけたらと思う。
今後テスト・SEO、Server Actionsについてをこのブログアプリを通して学べていけたらと思う。
参考
最後に
間違っていることがあればコードがおかしいとかあればコメントに書いていただけると幸いです。
よろしくお願いいたします。
Discussion