Vercel Postgresを使って簡単なToDoアプリケーションを作成する

## はじめに
Vercelは5月の1日から5日間続けて新機能の発表を行なってます。

https://vercel.com/ship

1日目にはVercel KVとVercel Postgres、Vercel Blobの3種類のStorage機能が発表されました。

https://vercel.com/blog/vercel-storage

この記事ではそのうちVercel Postgresについて焦点を当てて簡単なアプリケーションを作成してみます。

## Vercel Postgresとは
Vercel PostgresとはVercel Functionやフロントエンド用に構築された、[Neon](https://neon.tech/)を搭載したサーバーレスSQLデータベースです。
提供されるPostgres SQLのバージョンは15となります。
Vercel Postgresのデータベースはコールドスタートに最大5秒間かかり、最後のアクセスから5分間アクセスされない場合は停止されます。

### プラン
ProプランとHobbyプランからbeta版として利用することが可能で、どちらのプランからも1つのデータベースを無料で作成できます。Hobbyプランの場合1データベース1USDで追加で作成できます。Computing Timeなどの詳細な制限は[こちら](https://vercel.com/docs/storage/vercel-postgres/usage-and-pricing#billing-information)を参考にしてください。

### 利用できるORM
連携できるORMは[kysely](https://kysely.dev/)または、[prisma](https://www.prisma.io/)です。将来的には[drizzle](https://github.com/drizzle-team/drizzle-orm)もサポートされるそうです。

### 利用可能リージョン
Neonで提供されているリージョンと同じところを選択できます。利用するEdgeやVercel Functionで指定しているリージョンと同箇所を選択することが推奨されています。
利用可能なリージョンは以下の通りです。

| Region Code | Region Name | Location |
| --- | --- | --- |
| cle1 | us-east-2 | Cleveland, USA |
| iad1 | us-east-1 | Washington, D.C., USA |
| pdx1 | us-west-2 | Portland, USA |
| fra1 | eu-central-1 |Frankfurt, Germany |
| sin1 | ap-southeast-1 | Singapore |

日本がないのが残念ですね。現段階ではSingapore🇸🇬が最も近いので、リージョンに制約がない場合はSingaporeを選ぶのが無難と考えられます。

## 簡単なアプリケーションを作成する
サンプルアプリケーションとしてToDoリストを作成します。このアプリケーションではタスクの閲覧と完了だけが行えるものとします。

### 準備
まず、シンプルなNextjsのアプリケーションを作成します。
```
pnpm create next-app
```

![スクリーンショット 2023-05-05 15.42.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591069/ae550139-ba28-977b-a0ab-34800c07d31d.png)

`todo-list`というアプリケーションをTypeScriptとESLintとTailwind CSSを有効にして作成しました。`src`ディレクトリの利用と`App Router`も利用して、import aliasは`@/*`に設定しました。
そして、先にアプリケーションのコンポーネントを準備します。
まずはタスクの内容の表示と完了を行うための`Task`コンポーネントです。

```tsx:src/components/Task.tsx
import dayjs from "dayjs";

export default async function Task({ task }: { task: any }) {
  return (
    <div className="flex items-end justify-between bg-white p-4 border border-slate-200">
      <div className="flex">
        <TaskCompleted task={task} />
        <h2 className="text-4xl font-bold text-gray-700 ml-3">
          {task.title}
        </h2>
      </div>
      <p className="ml-3 text-xl text-gray-700">
        {dayjs(task.createdAt).format('YYYY/MM/DD')}
      </p>
    </div>
  );
}
```
チェックボックスはクライアントコンポーネントとして分けて定義します。
```tsx:src/components/TaskComplete.tsx
'use client';

import { ReactEventHandler, useState } from "react";

export default function TaskComplete({ task }: { task: any }) {
  const [checked, setChecked] = useState(task.completed);
  const onChange: ReactEventHandler<HTMLInputElement> = async (e) => {
    setChecked(e.currentTarget.checked);
  };

  return (
    <input type="checkbox" onChange={onChange} checked={checked} />
  );
}

```
![スクリーンショット 2023-05-05 16.35.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591069/3964fc4e-47bd-6b3a-c982-b2388bb1873f.png)
DBの値を取得する箇所や、チェックボックスによるタスクの切り替えは後ほど行います。
次にそれらを一覧で表示する`TaskBox`コンポーネントです。

```tsx:src/components/TaskBox.tsx
import Task from "./Task";

export default async function TaskBox() {
  const tasks = [{
    id: 1,
    title: 'Task 1',
    completed: false,
  }, {
    id: 2,
    title: 'Task 2',
    completed: false,
  }, {
    id: 3,
    title: 'Task 3',
    completed: false,
  }];

  return (
    <div className="bg-slate-300">
      <h1 className="p-4 text-6xl font-bold text-gray-700">Task Box</h1>
      {tasks.map((task) => {
        {/* @ts-expect-error Async Server Component */}
        return <Task key={task.id} task={task} />;
      })}
    </div>
  );
}
```
そしてそれをpage.tsxに埋め込みます。
```tsx:src/app/page.tsx
import TaskBox from '@/components/TaskBox'
import { Suspense } from 'react'

export default function Home() {
  return (
    <main>
      <Suspense fallback={<></>}>
        {/* @ts-expect-error Async Server Component */}
        <TaskBox />
      </Suspense>
    </main>
  )
}
```
![スクリーンショット 2023-05-05 17.07.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591069/c3557090-f6f0-bcde-30dc-b08e6a28238f.png)
現段階では定義された`tasks`の個数だけタスクを表示するだけのページです。`layout.tsx`の余計な情報も削除します。
```tsx:src/app/layout.tsx
import './globals.css'
import { Inter } from 'next/font/google'

const inter = Inter({ subsets: ['latin'] })

export const metadata = {
  title: 'ToDo App',
  description: 'Example ToDo App by Vercel Postgres',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="ja">
      <body className={inter.className}>{children}</body>
    </html>
  )
}
```
これで準備が完了です。

### データベースを作成する
[VercelのダッシュボードからStorage](https://vercel.com/dashboard/stores)を選択し、Create Databaseボタンを押します。
![スクリーンショット 2023-05-05 17.30.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591069/bec5ee44-eb10-aba1-9470-b7511ca41b4e.png)
Postgresを選択してデータベースの作成を開始します。
![スクリーンショット 2023-05-05 17.31.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591069/901f7fa4-96da-f362-3ac5-9ac60821a1b4.png)
Beta版での利用についての条文に同意後、作成するテーブル名とリージョンを聞かれます。テーブル名は`todo-list`、リージョンは一番近いSingaporeを選択しました。
![スクリーンショット 2023-05-05 17.34.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591069/f36e575a-4ef8-3efa-687f-8c201fb79d89.png)
Createを押すとそのテーブルのダッシュボードが開きます。無事ダッシュボードが表示されたらデータベースの作成は完了です。

### プロジェクトとデータベースを紐付ける
アプリケーションで先ほど作成したデータベースを利用できるようにします。
この記事ではVercelプロジェクトとの連携のためにvercelのcliを利用します。
vercelのcliは以下のようにインストールして利用できます。
```sh
pnpm i -g vercel@latest
```
`vercel -v`で正常に導入できていることを確認してください。
導入できたら、アプリケーションとcliをリンクさせます。
```sh
vercel link
```
このコマンドを入力して表示される情報に従って設定を完了したら、Vercelにプロジェクトが作成されます。作成したプロジェクトのダッシュボードでStorageを選択して先ほど作成したデータベースと連携させます。
![スクリーンショット 2023-05-05 17.59.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591069/f47c66f3-2b57-1004-f4ba-86f317b22e75.png)

![スクリーンショット 2023-05-05 20.23.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591069/f0ce3983-74a7-b60b-3828-d078612187a3.png)

そして、以下のコマンドを実行してVercelが持つプロジェクトの環境変数を取得します。
```sh
vercel env pull .env
```
`.gitignore`に`.env`を追加してgitを用いた管理を行わないようにするのを忘れないようにしてください。

### データベースの値を取得する
次はprismaを介してデータベースから値を取得します。
まずは準備です。
```sh
# 依存パッケージのインストール
pnpm i -D prisma

# prismaの準備
pnpm prisma init
```
作成された`.env`は先ほどVercelから取ってきた値に戻してください。
さらに、生成された`schema.prisma`を以下のように書き換えます。
```ts:prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["jsonProtocol"]
}

datasource db {
  provider          = "postgresql"
  url               = env("POSTGRES_PRISMA_URL") // uses connection pooling
  directUrl         = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
  shadowDatabaseUrl = env("POSTGRES_URL_NON_POOLING") // used for migrations
}

model tasks {
  id        Int      @id @default(autoincrement())
  title     String
  completed Boolean  @default(false)
  createdAt DateTime @default(now())
}
```
このファイルでToDoリストのテーブル定義を行なっています。
定義したテーブル情報を以下のコマンドでVercel Postgresに反映させます。
```sh
prisma generate && prisma db push
```

次にprismaをclientで扱うためのファイルを作ります。
```ts:src/lib/prisma.ts
import { PrismaClient } from '@prisma/client'

declare global {
  var prisma: PrismaClient | undefined
}

const prisma = global.prisma || new PrismaClient()

if (process.env.NODE_ENV === 'development') global.prisma = prisma

export default prisma
```

それを利用してテーブルの初期データを生成するseed.tsを作ります。
```ts:prisma/seed.ts
import prisma from '../src/lib/prisma'

async function main() {
  const response = await Promise.all([
    prisma.$executeRawUnsafe(`TRUNCATE TABLE tasks CASCADE;`),
    prisma.tasks.createMany({
      data: [
        {
          title: 'ゴミ捨て',
          completed: true,
        },
        {
          title: '宿題',
        },
        {
          title: '世界征服',
          completed: true,
        },
        {
          title: 'Learn React',
        },
      ],
    }),
  ])
  console.log(response)
}
main()
  .then(async () => {
    await prisma.$disconnect()
  })
  .catch(async (e) => {
    console.error(e)
    await prisma.$disconnect()
    process.exit(1)
  })
```
テーブルの初期値を簡単なコマンドで追加できるように、`package.json`に下記を書き足します。
```json:package.json
  "prisma": {
    "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
  },
```
`ts-node`がインストールされていない場合は`pnpm i ts-node`も行ってください。
完了後、下記コマンドを実行してテーブルに初期データを挿入します。
```sh
pnpm prisma db seed
```
初期データが挿入されていることはVercel Postgresのダッシュボードから確認できます。
![スクリーンショット 2023-05-05 22.53.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591069/740b3937-d275-965d-28fd-d837c69ba3eb.png)

最後に、`next dev`と`next build`を行う前にprismaの準備を行うようにコマンドを書き換えます。
```json:package.json
"dev": "prisma generate && next dev",
"build": "prisma generate && prisma db push && next build",
```
これでprismaとの連携は完了です。

### コンポーネントの書き換え
先ほど作成したコンポーネントをデータベースと通信するように書き換えます。
まずは、`TaskBox`です。
```tsx:src/components/TaskBox.tsx
import prisma from '@/lib/prisma';
import Task from "./Task";

export default async function TaskBox() {
  const tasks = await prisma.tasks.findMany({
    orderBy: {
      id: 'asc',
    }
  });

  return (
    <div className="bg-slate-300">
      <h1 className="p-4 text-6xl font-bold text-gray-700">Task Box</h1>
      {tasks.map((task) => {
        {/* @ts-expect-error Async Server Component */}
        return <Task task={task} key={task.id} />;
      })}
    </div>
  );
}
```
`tasks`をprisma経由でデータベースから取得したものに変更しました。

次に、チェックボックスを押した時にデータベースを更新するようにします。
まずは、データベースを更新するAPIを作ります。
```ts:src/app/api/tasks/[id]/route.ts
import prisma from "@/lib/prisma";
import { NextResponse } from 'next/server';

export async function PATCH(
  request: Request,
  {
    params,
  }: {
    params: { id: string };
  },
) {
  const req = await request.json();
  const task = await prisma.tasks.update(
    {
      where: { id: Number(params.id) },
      data: { completed: req.completed }
    }
  );
  return NextResponse.json({ task });
}
```
次に、`TaskCompleted`を書き換えます。
```tsx:src/components/TaskCompleted.tsx
'use client';

import { tasks } from "@prisma/client";
import { ReactEventHandler, useState } from "react";

export default function TaskComplete({ task }: { task: tasks }) {
  const [checked, setChecked] = useState(task.completed);
  const onChange: ReactEventHandler<HTMLInputElement> = async (e) => {
    const res = await fetch(`/api/tasks/${task.id}`, {
      method: 'PATCH',
      body: JSON.stringify({
        completed: e.currentTarget.checked
        })
      }
    );
    setChecked((await res.json()).task.completed);
  };

  return (
    <input type="checkbox" onChange={onChange} checked={checked} />
  );
}
```
先ほど作成したAPIを用いてチェックボックスの切り替えと同時にデータベースの値も更新するようにしました。

これでコンポーネントの書き換えは完了です。

![スクリーンショット 2023-05-05 23.19.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591069/600f0d44-8d2b-df76-b75e-7ed9ef3899ec.png)

### デプロイ
最後にこれまで作成したアプリケーションをVercel上にデプロイします。
```sh
vercel --prod
````
でCLIからデプロイできます。GUIの操作でも可能です。
![スクリーンショット 2023-05-05 23.20.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/591069/093d1cbf-423b-40d9-189e-c44695ccbb6c.png)