Supabase/Next.js/Prismaで簡単な日記アプリケーションを作って公開してみた

# はじめに
　今回、初めてCRUDの機能を備えたアプリケーションの開発をしたので、記憶が新しいうちに記事にしようと思い書きました。**エンジニア初心者の方に向けて**SupabaseやNext.jsなどの環境構築から簡単なアプリケーションの作り方の共有ができればと思います。

 :::note info
CRUDとは何か 
Create(生成)/Read(読み取り)/Update(更新)/Delete(削除)のデータを取り扱うソフトウェアに必要な4つの基本機能のことです。

:::

# 技術スタック
- TypeScript
- React
- tailwindcss
- Next.js
- Prisma
- Supabase
- Vercel


# 作ったもの
日記アプリ　
最近日記をつけようかと考えていたため、日記アプリを作ってみました。
これにログイン機能とか付け足して自分で使っていきたいなと思います:writing_hand:
CSSは適当です。。。

こちらのURLから見れます。
https://new-diary-app-sc1j.vercel.app/

![スクリーンショット 2024-08-04 11.34.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3582310/9c013e8a-4891-3237-4a53-56fa8790d7e2.png)

## 機能
- 日記が書ける
- 一覧表示ができる
- 編集、削除、投稿ができる


# 1. 環境構築
### リポジトリを作成する
まずリポジトリの作成をしましょう。
以下のURLから今回作成したリポジトリが見られます。ディレクトリ構造など参考にしてください。

https://github.com/natsukikokubu/NewDiaryApp

### Next.jsをインストール
次にNext.jsを自動セットアップをします。
Next.jsのDocumentを参考にセットアップしてみてください。
新しいディレクトリに移動して、ターミナルで以下のコマンドを入力します。
`npx create-next-app@latest`

:::note warn
App Routerは少し複雑になるため、Noを選択しています
:::


   
```sh
    Need to install the following packages:
    create-next-app@14.2.4
    Ok to proceed? (y) y
    ✔ What is your project named? … book-app
    ✔ Would you like to use TypeScript? … No / Yes # Yesを選択
    ✔ Would you like to use ESLint? … No / Yes　 # Yesを選択
    ✔ Would you like to use Tailwind CSS? … No / Yes　 # Yesを選択
    ✔ Would you like to use `src/` directory? … No / Yes　 # Yesを選択
    ✔ Would you like to use App Router? (recommended) … No / Yes　 # Noを選択
    ✔ Would you like to customize the default import alias (@/*)? … No / Yes # Yesを選択
    ✔ What import alias would you like configured? … @/*
    
```
    
### Supabaseのセットアップ

参考：https://supabase.com/docs/guides/getting-started/quickstarts/nextjs

New projecctボタンを押して以下のような画面から、プロジェクトの新規作成を行います。Project nameとPasswordを決めてください。

　![スクリーンショット 2024-07-30 16.28.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3582310/12d4234f-0b4a-5f2b-2209-3e80a233fae0.png)

:::note info
Passwordは後ほど使用するので覚えておいてください。
:::

ターミナル上で以下のコマンドを実行してsupabaseをinstallします。

 - `$ npm install @supabase/supabase-js`


 .envファイル(環境変数を格納しておくファイル)を作成して以下のようにコードを書いてください。
 .gitignoreはcommitしたくないファイルを置いておけるので、.gitignoreに.envを追加しておいてください。

```sh:.env
# <>の中身は自身のものを挿入してください。
NEXT_PUBLIC_SUPABASE_URL=<SUBSTITUTE_SUPABASE_URL>
NEXT_PUBLIC_SUPABASE_ANON_KEY=<SUBSTITUTE_SUPABASE_ANON_KEY>
```
 
以下はsupabaseのprojectのHOME画面です。右上のConnectを開くと接続に必要な情報が出てきます。
![スクリーンショット 2024-07-30 16.35.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3582310/ceb636dd-d13c-0616-78ab-35072f6659e7.png)

### 接続コードを書く

Supabaseと接続しましょう。

```ts
import { createClient } from "@supabase/supabase-js";

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;

// TypeScriptにsupabaseUrlとsupabaseKeyをstringとして扱ってもらいたいためにif文でガードしています
if (supabaseUrl === undefined || supabaseKey === undefined) {
  throw new Error("Missing Supabase URL or Key");
}

export const supabase = createClient(supabaseUrl, supabaseKey);

```

 
### PrismaとSupabase接続

参考：https://zenn.dev/kiriyama/articles/89bac9034bbe7a

以下のコマンドを実行してPrismaの初期化をします。

- `$ npm install prisma --save-dev`
- `$ npm install @prisma/client`
- `$ npx prisma init`

.envに必要な値を追加します。

以下の画像の情報を参照し、.env.localのDATABASE_URLとDIRECT_URLを.envファイルに追加します。

![スクリーンショット 2024-07-30 17.18.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3582310/b052a8b4-0f7f-129b-edf5-268e977b3bd3.png)

schema.prismaを更新します。
アプリケーションに応じて必要なデータモデルに設定してください。
今回のアプリケーションでは以下のようになります。

```
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

model diaries {
  id        Int      @id @default(autoincrement())
  title     String
  content   String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  date      DateTime @db.Date
}

model diary {
  id        Int      @id @default(autoincrement())
  title     String
  content   String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  date      DateTime @db.Date
}

```

migrateするために以下のコマンドをターミナルで実行してください。

`$ npx prisma migrate dev --name init`

:::note info
migrateとは作成したmodelをSQL文に変換してくれます。
:::

### データを試しに挿入してみる

何でもいいのでsupabaseにデータを挿入してみてください。
後でデータを読み込むため、その際に正しくデータ取得できているか確認してください。

## 2. APIを立てて日記一覧を取得する

先ず、getServerSidePropsからAPIを呼び出すときにbaseURLを取得する処理を共通化します。

```ts
import { GetServerSidePropsContext } from "next";

export const getApiBaseUrl = (req: GetServerSidePropsContext["req"]) => {
  const host = req.headers.host || "localhost:3000";
  const protocol = /^localhost/.test(host) ? "http" : "https";
  return `${protocol}://${host}/api`;
};

```

APIを立てて日記一覧を取得するコードを書きましょう。

```tsx
import type { NextApiRequest, NextApiResponse } from "next";
import { supabase } from "../../../utils/supabase";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method === "GET") {
    const data = await supabase.from("diaries").select("*");
    res.status(200).json(data);
  } else if (req.method === "POST") {
    const { title, content } = req.body;

    if (!title || !content) {
      return res.status(400).end("タイトル、内容は必須です");
    }
    try {
      const data = await supabase.from("diaries").insert([
        {
          title,
          content,
          date: "2021-09-01",
          updatedAt: new Date().toISOString(),
        },
      ]);

    } catch (e) {
      console.error(e);
      return res.status(500).end("Internal Server Error");
    }

    res.status(201).end();
  } else {
    res.setHeader("Allow", ["GET"]);
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }
}
```
早速叩いてみましょう。ターミナルで以下のコマンドを実行してください。

`$ curl http://localhost:3000/api/books`

:::note info
CurlとはコマンドラインからHTTPリクエストを送信できるツールです。
:::

## 3. 日記一覧をクライアント側で扱えるようにする

Diaryの型定義とindex.tsxにgetServerSidePropsを追加しましょう。

```tsx
export type Diary = {
  id: number;
  title: string;
  content: string;
  createdAt: string;
  updatedAt: string;
  date: string;
};

```

```tsx
import { getApiBaseUrl } from "../../utils/getApiBaseUrl";
import { GetServerSideProps, InferGetServerSidePropsType } from "next";
import { Diary } from "../../types/diary";
import { Button } from "./button";
import { useRouter } from "next/router";
import { useState } from "react";

type ServerProps = { diaries: Diary[] };

export const getServerSideProps: GetServerSideProps<ServerProps> = async ({
  req,
}) => {
  const res = await fetch(`${getApiBaseUrl(req)}/diaries`).then((res) =>
    res.json()
  );
  return {
    props: { diaries: res.data },
  };
};

```

## 4. 新規作成ページの実装

buttonコンポーネントを作成する。(好きなStyleを当ててください)

**button.tsx**

```tsx

import { ButtonHTMLAttributes, ComponentProps } from "react";

type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
  size?: "small" | "medium" | "large";
};

export const Button = ({
  children,
  onClick,
  size = "medium",
  ...rest
}: Props) => {
  const sizeClass = {
    small: "btn-small",
    medium: "btn-medium",
    large: "btn-large",
  };
  return (
    <button
      className="bg-blue-600 hover:bg-blue-800 text-white font-bold py-2 px-6 rounded-full"
      onClick={onClick}
      {...rest}
    >
      {children}
    </button>
  );
};

```

**new.tsx** を作成します。

```tsx

import { Button } from "./button";
import { useRouter } from "next/router";
import { FormEvent, useState } from "react";

export default function New() {
  const router = useRouter();
  const [hasError, setHasError] = useState(false);
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const form = new FormData(e.currentTarget);
    const title = form.get("title") as string;
    const content = form.get("content") as string;
    const date = form.get("date") as string;

    if (!title || !content) {
      setHasError(true);
      return;
    }

    setHasError(false);

    await fetch("/api/diaries", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ title, content }),
    }).then(() => {
      router.push("/");
    });
  };

  return (
    <main className="text-gray-700">
      <h1 className="text-5xl font-bold my-4 ">Diary App</h1>
      <h2 className="text-4xl font-semibold my-4">日記を追加</h2>

      <form onSubmit={handleSubmit}>
        <div>
          <label className="text-lg font-bold mb-2" htmlFor="title">
            タイトル
          </label>
          <input
            className="border border-gray-400 rounded-lg p-4 my-4"
            type="text"
            name="title"
          />
        </div>
        <div>
          <label className="text-lg font-bold mb-2" htmlFor="content">
            日付け
          </label>
          <input
            className="border border-gray-400 rounded-lg p-4 my-4"
            type="date"
            name="date"
          />
        </div>
        <div className="mb-6">
          <label className="text-lg font-bold mb-2" htmlFor="content">
            日記
          </label>
          <textarea
            className="border border-gray-400 rounded-lg p-4 my-4"
            name="content"
          />
        </div>

        <Button size="medium" type="submit">
          保存
        </Button>
        {hasError && <p>入力に誤りがあります</p>}
      </form>
    </main>
  );
}

```

index.tsxの日記追加ボタンにonClickハンドラーを渡して/newページに遷移できるようにします。

```tsx
export default function Home(props: Props) {
  const router = useRouter();
  const [deleted, setDeleted] = useState(false);
  const handleClickNewDiary = () => {
    router.push("/new");
  };
  const handleClickDeleteButton = async (id: number) => {
    await fetch(`/api/diaries/${id}`, {
      headers: {
        "Content-type": "application/json",
      },
      method: "DELETE",
    }).then(() => {
      setDeleted(true);
      router.replace(router.asPath);
    });
  };
  return (
    <main className="text-gray-700">
      <h1 className="text-5xl font-bold my-4 ">Diary App</h1>
      <div>
        <h2 className="text-4xl font-semibold my-4">日記一覧</h2>
        <Button size="large" onClick={handleClickNewDiary}>
          日記作成 +
        </Button>
      </div>
```

## 5. 編集機能追加

編集をするためには個々の日記にアクセスをしなければなりません。そのため、ダイナミックルーティング(動的ルーティング)を使用してアクセスできるようにしましょう。
**diaries/[id].ts**

```ts
import { supabase } from "../../../../utils/supabase";
import type { NextApiRequest, NextApiResponse } from "next";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method === "GET") {
    const supabaseRes = await supabase
      .from("diaries")
      .select("*")
      .eq("id", req.query.id);
    if (Array.isArray(supabaseRes.data)) {
      res.status(200).json(supabaseRes.data[0]);
    } else {
      res.status(404).json({ message: "Not Found" });
    }
  } else if (req.method === "PUT") {
    const { title, content, date } = req.body;
    await supabase
      .from("diaries")
      .update({ title, content, date })
      .eq("id", req.query.id);
    res.status(200).json({ message: "Updated" });
  } else if (req.method === "DELETE") {
    await supabase.from("diaries").delete().eq("id", req.query.id);
    res.status(200).json({ message: "Deleted" });
  } else {
    res.setHeader("Allow", ["GET"]);
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }
}
```
**pages/[id]/edit.tsx**

```tsx
import { Button } from "../button";
import { Diary } from "../../../types/diary";
import { getApiBaseUrl } from "../../../utils/getApiBaseUrl";
import { GetServerSideProps, InferGetServerSidePropsType } from "next";
import { useRouter } from "next/router";
import { FormEvent, useState } from "react";

type ServerProps = { diary: Diary };

export const getServerSideProps: GetServerSideProps<ServerProps> = async ({
  query,
  req,
}) => {
  const id = query.id;
  const res = await fetch(`${getApiBaseUrl(req)}/diaries/${id}`).then((res) =>
    res.json()
  );
  return {
    props: { diary: res },
  };
};

type Props = InferGetServerSidePropsType<typeof getServerSideProps>;

export default function Edit({ diary }: Props) {
  const router = useRouter();
  const [hasError, setHasError] = useState(false);
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const form = new FormData(e.target as HTMLFormElement);
    const title = form.get("title") as string;
    const content = form.get("content") as string;
    const date = form.get("date") as string;

    if (!title || !content || !date) {
      setHasError(true);
      return;
    }

    setHasError(false);

    await fetch(`/api/diaries/${router.query.id}`, {
      method: "PUT",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ title, content, date }),
    }).then(() => {
      router.push("/");
    });
  };

  return (
    <main className="text-gray-700">
      <h1 className="text-5xl font-bold my-4 ">Diary App</h1>
      <h2 className="text-4xl font-semibold my-4">日記の編集</h2>

      <form onSubmit={handleSubmit}>
        <div>
          <label className="text-lg font-bold mb-2" htmlFor="title">
            タイトル
          </label>
          <input
            className="border border-gray-400 rounded-lg p-4 my-4"
            name="title"
            defaultValue={diary.title}
          />
        </div>
        <div>
          <label className="text-lg font-bold mb-2" htmlFor="content">
            日付け
          </label>
          <input
            className="border border-gray-400 rounded-lg p-4 my-4"
            name="date"
            defaultValue={diary.date}
          />
        </div>
        <div className="mb-6">
          <label className="text-lg font-bold mb-2" htmlFor="content">
            日記
          </label>
          <textarea
            className="border border-gray-400 rounded-lg p-4 my-4"
            name="content"
            defaultValue={diary.content}
          />
        </div>

        <Button size="medium" type="submit">
          保存
        </Button>
        {hasError && <p>入力に誤りがあります</p>}
      </form>
    </main>
  );
}
```

## 6. 削除機能追加

削除ボタンを追加してindex.tsx,diaries/[id].tsを編集する。
index.tsxではdeleteボタンにハンドラーを渡してクリックしたら、stateがtrueになるようにします。

```tsx
const [deleted, setDeleted] = useState(false);

const handleClickDeleteButton = async (id: number) => {
    await fetch(`/api/diaries/${id}`, {
      headers: {
        "Content-type": "application/json",
      },
      method: "DELETE",
    }).then(() => {
      setDeleted(true);
      router.replace(router.asPath);
    });
  };
```

[id].tsではelse if文でDELETE methodを使用して削除できるようにする。

```ts
... else if (req.method === "DELETE") {
    await supabase.from("diaries").delete().eq("id", req.query.id);
    res.status(200).json({ message: "Deleted" });
  } else { ...
```

## 7. Deployする
今回はVercelでデプロイしました。
1. VercelとGithubを接続する
1. New Buttonでprojectを選択する
1. 適切なGithubプロジェクトをimportする
1. .envファイルのKeyとValueを正しく入力する
1. Deployする

![スクリーンショット 2024-08-01 19.01.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3582310/8c8c7d93-1798-2f3d-eb73-4ec0d9f35e97.png)


## 最後に
ここまで記事を読んでくださりありがとうございました！
今回は初めてのCRUDアプリケーションの開発ということで最低限の機能しか兼ね備えていません。そのため、CSSの修正やログイン機能を搭載するなどをしてUpdateしていけたらと思っています。そして今後も記事に残していきます。まだまだ学習中であるため、間違いがありましたら、ご指摘いただければ幸いです。
プログラミング初心者の皆さん、一緒に頑張っていきましょう:thumbsup_tone1: