Next.js×Prisma×Vercel Storageで作るブログアプリ②

投稿日 2024-08-21

更新日 2024-08-22

プログラミング

Next.js、Prisma、Vercel Storageを使って、サーバーレス環境でブログアプリを開発する方法を紹介しまます。本記事はブログの投稿とデータの取得までを解説します。

はじめに

この記事はNext.js × Prisma × Vercel Storageで作るブログアプリ①の続きです。まだ読んでいない方はそちらから閲覧してください。Nextjsとprismaのセットアップが容易であるならばこの記事から読み始めてください。また、以下のソースコードを参考にしてください。インデックスーぺ時等のレイアウトや投稿フォーム・ボタンはコンポーネント化していますが解説はしませんのでコピー&ペーストしてください。

開発環境

  • Nextjs:14.2.5(App Router)
  • React:^18
  • Node.js:20.1.3
  • Prisma:5.17.0
  • tailwindcss 3.4.1

カテゴリーの追加と取得

カテゴリーの追加

はじめにutilsフォルダー配下にprisma.tsを作成してprismaのインスタンスの作成と中間テーブルへの自動紐づけを定義します。

/utils/prisma.ts
import { PrismaClient } from '@prisma/client';

export const prisma = new PrismaClient();

export async function connect() {
  try {
    await prisma.$connect();
  } catch (error) {
    return Error('Failed to connect to the database');
  }
}

カテゴリー追加機能をServer Action で実装します。actionsフォルダー下に作成したprismaロジックです。

/actions/postAction.ts
'use server'
import { connect, prisma } from '@/utils/prisma';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export const postCategory = async (FormData: FormData) => {
  const name = FormData.get('name') as string;
  try {
    await prisma.category.create({
      data: {
        name: name,
      },
    });
  } catch (error) {
    throw new Error('カテゴリーの追加に失敗しました');
  }
  revalidatePath('/category');
  redirect('/category');
};

フォームからカテゴリーネームのnameを受け取ってデータベースに登録します。登録が完了したらページを/categoryのキャッシュを消去してリダイレクトさせま。

次に画面側のフォームを実装します。category及びaddフォルダを作成しformタグのactionに別ファイルで定義したArticlePostを呼び出します。Server Actionの書き方は公式ページを参照してください。

app/category/add/page.tsx
import { postCategory } from '@/actions/postAction';
import SubmitButton from '@/components/Button/SubmitButton';
import Input from '@/components/Form/Input';

export default function CategoryAddPage() {
  return (
    <div>
      <h1 className='text-2xl font-semibold mb-4'>カテゴリ追加</h1>
      <form
        action={async (formdata: FormData) => {
          'use server';
          await postCategory(formdata);
        }}
      >
        <Input label='カテゴリ名' name='name' type='text' className='mb-3' />
        <SubmitButton className='bg-green-400 hover:bg-green-300'>
          追加
        </SubmitButton>
      </form>
    </div>
  );
}

フォームにカテゴリー名を入力してボタンをクリックするとデータベースに帆残されます。今回は実装していませんがpostActio.tsにバリデーションを行た方がよいと公式で推奨されています。

カテゴリーの取得

続いてVercel Storageからカテゴリーを取得します。以下はutilsフォルダー下に作成したprismaロジックです。

utils/getCategory.ts
import { prisma, connect } from '@/utils/prisma';

export const getCategories = async () => {
  try {
    const categories = await prisma.category.findMany();
    return categories;
  } catch (error) {
    throw new Error('カテゴリーの取得に失敗しました');
  }
};

特にデータを絞る必要がないのでfindManyメソッドを使用します。

続いて、カテゴリーを表示する画面を作ります。

app/category/page.tsx
import { getCategories } from '@/utils/getCategory';
import { Category } from '@/types/Category';
import LinkButton from '@/components/Button/LinkButton';

export default async function CategoryPage() {
  const categories = await getCategories();
  if (!categories) return <div>カテゴリがありません</div>;
  return (
    <div>
      <h1 className='text-2xl font-semibold'>カテゴリ一覧</h1>
      <div className='space-y-4 mt-8'>
        {categories.map((category: Category) => (
          <div key={category.id} className='flex items-center justify-between'>
            <h2 className='text-xl font-semibold'>{category.name}</h2>
            <LinkButton
              href={`/category/edit/${category.id}`}
              className='bg-green-400 hover:bg-green-300'
            >
              編集
            </LinkButton>
          </div>
        ))}
      </div>
    </div>
  );
}

非同期でカテゴリーを取得してmap関数で展開しています。カテゴリーidをもとに編集画面へのリンクも用意しておきます。次のブログ記事で編集と削除機能を解説します。また、typesフォルダーに型を定義していますが、any型を手ぎしても問題ありません。

ここまでで一度カテゴリーを追加できている試してみてください。カテゴリー追加ページで入力してボタンを押すしデータベースに保存することができたらカテゴリー十プページに遷移するので確かめることができます。画像のように表示されていばよいです。

記事の投稿と取得

記事の投稿

次に記事を投稿するロジックをactionPostに追記します。

actions/postAction.ts

フォームからタイトル、本文、複数のカテゴリーidを受け取ります。ここでconnect()が重要になります。カテゴリーは中間テーブルに保存するように定義しているので上記コードのように記述します。また、複数のカテゴリーを登録することを許容しているので受け取ったカテゴリーidをmapで展開します。

続いて投稿画面を実装していきます。

app/article/add/page.tsx

カテゴリーは事前にを取得して、記事を投稿する時にチェックボックスで選択できるようしています。

記事詳細ページの取得

最後に記事詳細ページを実装します。記事詳細ページへのリンクは/articleのpage.tsxに設置しており、そのページのためのデータを取得する関数(utils/getArticle.ts)はgithubのソースコードを参照して下さい。

まずは、データを取得する関数を定義します、utilsフォルダー下にgetArticleを作成して以下のように編集してください。

utils/getArticle.ts
import { prisma, connect } from '@/utils/prisma';
export const getArticleDetail = async (id: string) => {
  await connect();
  try {
    const article = await prisma.article.findUnique({
      where: { id: id },
      include: { categories: true },
    });
    return article;
  } catch (error) {
    throw new Error('記事の取得に失敗しました');
  } finally {
    await prisma.$disconnect();
  }
};

ブログのidを参照して詳細ページのデータを取得します。同時にブログに紐づけられたカテゴリーデータも表示したいのでincludeを使用してデータベースから取得してきます。

以下はブログ記事詳細ページのであり、ブログを少し加工して表示するためのコードが含まれています。

app/article/[id]/page.tsx
import { getArticleDetail } from '@/utils/getArticle';
import dayjs from 'dayjs';
import { markdownToHtml } from '@/utils/markdown';
import styles from '@/styles/content.module.css';
import { notFound } from 'next/navigation';
export default async function ArticleDetailPage({
  params,
}: {
  params: { id: string };
}) {
  const post = await getArticleDetail(params.id);
  if (!post) return notFound();

  const contentn = post.content ? await markdownToHtml(post.content) : '';

  return (
    <div className=''>
      <h1 className='text-2xl font-semibold'>{post.title}</h1>
      <div className='flex flex-col space-y-1 pt-3'>
        <span className='text-sm text-gray-500'>
          投稿日:{dayjs(post.createdAt).format('YYYY/MM/DD')}
        </span>
        {post.updatedAt && (
          <span className='text-sm text-gray-500'>
            更新日:{dayjs(post.updatedAt).format('YYYY/MM/DD')}
          </span>
        )}
      </div>
      {post.categories && (
        <div className='flex gap-3 py-3'>
          {post.categories.map((category) => (
            <span
              key={category.id}
              className='bg-slate-400 p-1 text-sm rounded-md text-white'
            >
              {category.name}
            </span>
          ))}
        </div>
      )}
      <div
        className={styles.content}
        dangerouslySetInnerHTML={{ __html: contentn }}
      />
    </div>
  );
}

ブログの投稿時間、更新時間はdayjsで見やすくするために加工しました。ブログ本文はマークダウン形式で投稿してserver Copmponent内ででHTMLに変換しています。その変換にはRemark/Rehyperを使用して以下の記事を参考にさせていただきました。

ブログ本文は生成AIのプロンプトで「初投稿のブログ記事原稿を提案してください。マークダウン形式でお願いします。」などと入力すればダミーの原稿を作成せてくれるので活用してみてください。また、本文は別ファイルのcontent.module.cssで装飾しています。画像のような詳細ページができるはずです。

まとめ

今回は以下の機能を実装しました。

  • カテゴリーの追加(Create)とデータ取得(Read)
  • 記事の投稿(Create)と詳細データの取得及び加工(Read)

次回の記事ではカテゴリーとブログの編集(Update)と削除(Delete)機能を投稿します。だらだらとソースコードを並べただけですが最後まで読んでいただきありがとうございました。

参考文献