😄

Next.js (app router)とmicroCMSの連携して簡易ブログを作ってみる

2024/11/30に公開

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についてをこのブログアプリを通して学べていけたらと思う。

参考

最後に

間違っていることがあればコードがおかしいとかあればコメントに書いていただけると幸いです。
よろしくお願いいたします。

GitHubで編集を提案

Discussion