Next.jsにDB追加してVercelで公開してみた①

## この記事の目的
- Next.jsでDBを使用してサイト構築する際の備忘録
- SupabaseでPostgreSQLのDB作成する際のフロー確認用
- Next.jsにDBを追加した際にVercelでデプロイするフロー

## 記事の概要
今回はNext.jsにBaaS（Backend as a Service）であるSupabaseでPostgreSQLをDBとして用意し、超簡易的なブログ投稿サイトを公開するところまでを行っていきたいと思います。
要はNext.jsにバックエンドも追加してフルスタックのサイトを公開しようってことになります！
また、目的としてはNext.jsにDB（バックエンド部分）追加をして公開できるようにすることなので、スタイルや機能面は無視しています

また、本記事では自分用にまとめているので、細かい内容については下記の記事を参考にしてみて下さい！
- [Next.js、Prisma、および PostgreSQL を使用してフルスタック アプリを構築する方法](https://vercel.com/guides/nextjs-prisma-postgres)
- [Next.js､ Prisma､PostgreSQLでフルスタックアプリを作る](https://qiita.com/tomohiko_ohhashi/items/da804ed1f5870c9ce52d)
後者の記事は前者の記事を日本語に翻訳してくれているみたいです。

## 使用技術と前提
### 使用技術
- Next.js(バージョンは12)
- react
- typescript
- prisma
- Supabase

### 前提
- GitHubアカウント (OAuthアプリを作成するために使用)
- Googleアカウント (OAuthアプリを作成するために使用)
- Vercelアカウント (作成したアプリをデプロイするために使用)
- Supabaseアカウント (PostgreSQLをDBとして用意するために使用)

## プロジェクト公開までのフロー
- **①Next.jsのプロジェクト作成**
- **②prismaインストール**
- **③supabaseの設定**
- **④DB設定（schema.prismaでテーブル作成）**
- **⑤投稿データ取得等の処理実装（Prisma Client）**
- ⑥NextAuth.js ライブラリをインストール
- ⑦ログイン機能追加
- ⑧GitHub認証設定
- ⑨Google認証設定
- ⑩Vercelで公開してみる

※長くなるためこの記事では①〜⑤までをまとめています。[⑥以降はこちらの記事](https://qiita.com/pikimaru/items/3a7aff9dbd052796629a)でまとめています
※バックエンドを実装してVercel公開できることを目的としているので、コードは載せていますが細かい説明せずにさらっと説明しているので、ソースコード全体については[こちらのGitHub](https://github.com/Yuichi-W/prisma_supabase.git)を参考にして下さい。

## ①Next.jsのプロジェクト作成
### Next.jsのプロジェクトを作成（下記のコマンドでクローンして下さい）
```
npx create-next-app --example https://github.com/prisma/blogr-nextjs-prisma/tree/main blogr-nextjs-prisma
```
### 作成したプロジェクトに移動しサーバー起動
```
cd blogr-nextjs-prisma
npm run dev
```
`npm run dev`の後`https://localhost:3000`で下画像のように表示されるかと思います
![スクリーンショット 2023-04-20 22.12.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/462658/67e1395d-e7a9-ecc1-96dd-5e1e8dedf8f4.png)

## prismaインストール
### prismaインストール
```
npm install prisma --save-dev
```
### prisma初期化
```
npx prisma init
```
初期化すると以下のファイルが生成される
- `env`
- `prisma/schema.prisma`
`.env`にはDBの接続先設定。`schema.prisma`に使用するデータベースとテーブル設定。
`.env`は③で設定の説明をしています
`schema.prisma`は④で設定の説明をしています

## ③supabaseの設定
### supabaseでプロジェクト作成
こちらについては下記の「手順３」まで行って下さい
[Prisma で使用する無料の PostgreSQL データベースを Supabase にセットアップする](https://dev.to/prisma/set-up-a-free-postgresql-database-on-supabase-to-use-with-prisma-3pk6)
もしくは下記の記事の「プロジェクト名等の入力・選択」まで行って下さい
[SupabaseでPostgreSQLのDB作成](https://qiita.com/pikimaru/items/5e51d36250c288b8b6dc#%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E5%90%8D%E7%AD%89%E3%81%AE%E5%85%A5%E5%8A%9B%E9%81%B8%E6%8A%9E)
### PostgreSQLデータベースの接続URLの設定
プロジェクト作成後「Project Setting」->「Database」->「Connection string」->「URI」を選択しコピー
![スクリーンショット 2023-04-20 22.34.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/462658/ae60109e-f9a3-27b0-3a43-32488c2a966a.png)
#### .envにDBの接続先設定
例としてパスワードが`password1234`の場合のDATABASE_URLを記載しています
※パスワードはプロジェクト作成時に記入したものになります。
```.env
DATABASE_URL="postgresql://postgres:password1234@db.vnsuouoysyhgsevojusk.supabase.co:5432/postgres"
```

## ④DB設定（schema.prismaでテーブル作成）
必要なテーブル定義を`schema.prisma`に下記のように記載していきます。
`Account`や`User`のカラムについては認証で必要になるためこのようになっています
```prisma/schema.prisma
// schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

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

model Post {
  id        String  @id @default(cuid())
  title     String
  content   String?
  published Boolean @default(false)
  author    User?   @relation(fields: [authorId], references: [id])
  authorId  String?
}

model Account {
  id                 String  @id @default(cuid())
  userId             String  @map("user_id")
  type               String
  provider           String
  providerAccountId  String  @map("provider_account_id")
  refresh_token      String?
  access_token       String?
  expires_at         Int?
  token_type         String?
  scope              String?
  id_token           String?
  session_state      String?
  oauth_token_secret String?
  oauth_token        String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
  @@map("accounts")
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique @map("session_token")
  userId       String   @map("user_id")
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@map("sessions")
}

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  emailVerified DateTime? @map("email_verified")
  image         String?
  createdAt     DateTime  @default(now()) @map(name: "created_at")
  updatedAt     DateTime  @updatedAt @map(name: "updated_at")
  posts         Post[]
  accounts      Account[]
  sessions      Session[]

  @@map(name: "users")
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
  @@map("verificationtokens")
}
```
### DBスキーマの同期
下記コマンドでDBスキーマの同期を行います。（テーブル作成）
```
npx prisma db push
```
成功すると下記のようなメッセージがでるかと思います
```
Environment variables loaded from /Users/nikolasburk/Desktop/nextjs-guide/blogr-starter/.env 
Prisma schema loaded from prisma/schema.prisma

🚀  Your database is now in sync with your schema. Done in 2.10s
```
これでSupabaseの画面でも下のようにテーブルが作成されているのが確認できるかと思います
![スクリーンショット 2023-04-20 23.02.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/462658/a441e84e-6f6c-24e7-68fc-450aa10641f8.png)

### 【補足】Prisma Studioでも確認してみる
Supabaseがあるので今回は必要はあまりないですがPrisma Studioでもテーブルの確認やデータの管理ができるのでこちらも記載しています
Prisma studioというデータベースを操作するためのGUIが起動。
```
npx prisma studio
```
上記実行すると下記のようにメッセージが表示されるかと思いますので`http://localhost:5555`を開いてみましょう
```
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Prisma Studio is up on http://localhost:5555
```
`http://localhost:5555`を開くと下の画像のようにテーブルが追加されているかと思います。
自分はデータを追加しているのでデータが入っていますが、実際はまだ入っていないかと思います
![スクリーンショット 2023-04-20 23.00.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/462658/7707164c-37fe-1e1a-8e3f-84bd3eb7e0e5.png)

## ⑤投稿データ取得等の処理実装（Prisma Client）
まず基本的なデータ取得を`Prisma Client`を使用して実装していきます
### Prisma Client をインストール
```
npm install @prisma/client
```
### Prisma Client を生成
```
npx prisma generate
```

### Prismaクライアントへの接続部分を作成
`lib/prisma.ts`を作成しPrismaクライアントへの接続部分を実装する

<details><summary>lib/prisma.tsコードをクリックして全て表示</summary><div>

```lib/prisma.ts
// PrismaClientをインポート
import { PrismaClient } from '@prisma/client';

// PrismaClientインスタンスを初期化
let prisma: PrismaClient;

// NODE_ENVが'production'かを確認
if (process.env.NODE_ENV === 'production') {
  // プロダクション環境の場合、新しいPrismaClientインスタンス作成
  prisma = new PrismaClient();
} else {
  // 開発環境の場合、グローバルなPrismaClientインスタンスが存在するかを確認
  if (!global.prisma) {
    global.prisma = new PrismaClient();
  }
  prisma = global.prisma;
}

export default prisma;

```

</div></details>


### 公開されたすべての投稿を取得する処理の実装
`pages/index.tsx`を作成し投稿を取得する処理の実装をする
`lib`ディレクトリを作成し、配下に`prisma.ts`を作成して下さい
<details><summary>pages/index.tsxコードをクリックして全て表示</summary><div>

```pages/index.tsx
import React from "react"
import { GetStaticProps } from "next"
import Layout from "../components/Layout"
import Post, { PostProps } from "../components/Post"

// PrismaClientのインスタンスをインポートします。
import prisma from '../lib/prisma';

// GetStaticProps関数を実装して、ページの初期データを取得
export const getStaticProps: GetStaticProps = async () => {
  // PrismaClientインスタンスを使用して、公開された記事をすべて取得
  const feed = await prisma.post.findMany({
    where: { published: true },
    include: {
      author: {
        select: { name: true },
      },
    },
  });
  // ページの初期データを返します。
  return {
    props: { feed },
    revalidate: 10,
  };
};

// 型定義
type Props = {
  feed: PostProps[]
}

const Blog: React.FC<Props> = (props) => {
  return (
    <Layout>
      <div className="page">
        <h1>Public Feed</h1>
        <main>
          {props.feed.map((post) => (
            <div key={post.id} className="post">
              <Post post={post} />
            </div>
          ))}
        </main>
      </div>
      <style jsx>{`
        .post {
          background: white;
          transition: box-shadow 0.1s ease-in;
        }

        .post:hover {
          box-shadow: 1px 1px 3px #aaa;
        }

        .post + .post {
          margin-top: 2rem;
        }
      `}</style>
    </Layout>
  )
}

export default Blog

```
</div></details>


### その他のブログ機能作成（新規投稿/下書き/公開/指定IDの投稿取得/削除）
#### 新規投稿
`pages/create.tsx`を作成
`pages/api/post/index.ts`を作成

<details><summary>pages/create.tsxコードをクリックして全て表示</summary><div>

```pages/create.tsx
import React, { useState } from 'react';
import Layout from '../components/Layout';
import Router from 'next/router';

const Draft: React.FC = () => {
  // タイトルとコンテンツの状態管理
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');

  // フォームデータ送信関数を定義
  const submitData = async (e: React.SyntheticEvent) => {
    e.preventDefault();
    try {
      // 送信データを作成
      const body = { title, content };
      // APIエンドポイントにPOSTリクエストを送信
      await fetch('/api/post', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      });
      // データ送信後、下書き一覧ページにリダイレクト
      await Router.push('/drafts');
    } catch (error) {
      console.error(error);
    }
  };

  return (
    <Layout>
      <div>
        <form onSubmit={submitData}>
          <h1>New Draft</h1>
          <input
            autoFocus
            onChange={(e) => setTitle(e.target.value)}
            placeholder="Title"
            type="text"
            value={title}
          />
          <textarea
            cols={50}
            onChange={(e) => setContent(e.target.value)}
            placeholder="Content"
            rows={8}
            value={content}
          />
          <input disabled={!content || !title} type="submit" value="Create" />
          <a className="back" href="#" onClick={() => Router.push('/')}>
            or Cancel
          </a>
        </form>
      </div>
      <style jsx>{`
        .page {
          background: var(--geist-background);
          padding: 3rem;
          display: flex;
          justify-content: center;
          align-items: center;
        }

        input[type='text'],
        textarea {
          width: 100%;
          padding: 0.5rem;
          margin: 0.5rem 0;
          border-radius: 0.25rem;
          border: 0.125rem solid rgba(0, 0, 0, 0.2);
        }

        input[type='submit'] {
          background: #ececec;
          border: 0;
          padding: 1rem 2rem;
        }

        .back {
          margin-left: 1rem;
        }
      `}</style>
    </Layout>
  );
};

export default Draft;
```
</div></details>

<details><summary>pages/api/post/index.tsコードをクリックして全て表示</summary><div>

```pages/api/post/index.ts
import { getSession } from 'next-auth/react';
import prisma from '../../../lib/prisma';

export default async function handle(req, res) {
  // タイトルとコンテンツを抽出
  const { title, content } = req.body;

  // セッション取得。
  const session = await getSession({ req });

  // PrismaClientを使用して、新しい記事を作成。
  const result = await prisma.post.create({
    data: {
      title: title,
      content: content,
      author: { connect: { email: session?.user?.email } },
    },
  });
  // レスポンスとして、作成された記事をJSON形式で返す
  res.json(result);
}
```
</div></details>

#### 下書き機能
`pages/drafts.tsx`を作成

<details><summary>pages/drafts.tsxコードをクリックして全て表示</summary><div>

```pages/drafts.tsx

import React from 'react';
import { GetServerSideProps } from 'next';
import { useSession, getSession } from 'next-auth/react';
import Layout from '../components/Layout';
import Post, { PostProps } from '../components/Post';
import prisma from '../lib/prisma';

// 下書きの一覧を取得
export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
  // クライアントのセッション取得
  const session = await getSession({ req });
  // セッションが存在しない場合、403ステータスコードと空の下書きリストを返す
  if (!session) {
    res.statusCode = 403;
    return { props: { drafts: [] } };
  }
  // セッションが存在する場合、下書き記事リスト取得
  const drafts = await prisma.post.findMany({
    where: {
      author: { email: session.user.email },
      published: false,
    },
    include: {
      author: {
        select: { name: true },
      },
    },
  });

  // 下書き記事のリストをpropsとして返す
  return {
    props: { drafts },
  };
};

type Props = {
  drafts: PostProps[];
};

const Drafts: React.FC<Props> = (props) => {
  const { data: session } = useSession();

  if (!session) {
    return (
      <Layout>
        <h1>My Drafts</h1>
        <div>You need to be authenticated to view this page.</div>
      </Layout>
    );
  }

  return (
    <Layout>
      <div className="page">
        <h1>My Drafts</h1>
        <main>
          {props.drafts.map((post) => (
            <div key={post.id} className="post">
              <Post post={post} />
            </div>
          ))}
        </main>
      </div>
      <style jsx>{`
        .post {
          background: var(--geist-background);
          transition: box-shadow 0.1s ease-in;
        }

        .post:hover {
          box-shadow: 1px 1px 3px #aaa;
        }

        .post + .post {
          margin-top: 2rem;
        }
      `}</style>
    </Layout>
  );
};

export default Drafts;
```
</div></details>

#### 公開機能
`pages/api/publish/[id].ts`を作成
`pages/p/[id].tsx`を作成

<details><summary>pages/api/publish/[id].tsコードをクリックして全て表示</summary><div>

```pages/api/publish/[id].ts
import prisma from '../../../lib/prisma';

export default async function handle(req, res) {
  // 公開する記事のIDを取得
  const postId = req.query.id;

  // 記事を更新、publishedフラグをtrueに設定
  const post = await prisma.post.update({
    where: { id: postId },
    data: { published: true },
  });

  // 更新された記事をJSON形式で返す
  res.json(post);
}

```
</div></details>

<details><summary>pages/p/[id].tsxコードをクリックして全て表示</summary><div>

```pages/p/[id].tsx
import React from 'react';
import { GetServerSideProps } from 'next';
import ReactMarkdown from 'react-markdown';
import Router from 'next/router';
import Layout from '../../components/Layout';
import { PostProps } from '../../components/Post';
import { useSession } from 'next-auth/react';
import prisma from '../../lib/prisma';

export const getServerSideProps: GetServerSideProps = async ({ params }) => {
  const post = await prisma.post.findUnique({
    where: {
      id: String(params?.id),
    },
    include: {
      author: {
        select: { name: true, email: true },
      },
    },
  });
  return {
    props: post,
  };
};

async function publishPost(id: string): Promise<void> {
  await fetch(`/api/publish/${id}`, {
    method: 'PUT',
  });
  await Router.push('/');
}

async function deletePost(id: string): Promise<void> {
  await fetch(`/api/post/${id}`, {
    method: 'DELETE',
  });
  Router.push('/');
}

const Post: React.FC<PostProps> = (props) => {
  const { data: session, status } = useSession();
  if (status === 'loading') {
    return <div>Authenticating ...</div>;
  }
  const userHasValidSession = Boolean(session);
  const postBelongsToUser = session?.user?.email === props.author?.email;
  let title = props.title;
  if (!props.published) {
    title = `${title} (Draft)`;
  }

  return (
    <Layout>
      <div>
        <h2>{title}</h2>
        <p>By {props?.author?.name || 'Unknown author'}</p>
        <ReactMarkdown children={props.content} />
        {!props.published && userHasValidSession && postBelongsToUser && (
          <button onClick={() => publishPost(props.id)}>Publish</button>
        )}
        {userHasValidSession && postBelongsToUser && (
            <button onClick={() => deletePost(props.id)}>Delete</button>
        )}
      </div>
      <style jsx>{`
        .page {
          background: var(--geist-background);
          padding: 2rem;
        }

        .actions {
          margin-top: 2rem;
        }

        button {
          background: #ececec;
          border: 0;
          border-radius: 0.125rem;
          padding: 1rem 2rem;
        }

        button + button {
          margin-left: 1rem;
        }
      `}</style>
    </Layout>
  );
};

export default Post;

```
</div></details>

#### 指定IDの投稿取得/削除機能
`pages/api/post/[id].ts`を作成

<details><summary>pages/api/post/[id].tsコードをクリックして全て表示</summary><div>

```pages/api/post/[id].ts
import prisma from '../../../lib/prisma';

export default async function handle(req, res) {
  // 記事のIDを取得
  const postId = req.query.id;

  // HTTPメソッドがDELETEの場合、記事を削除
  if (req.method === 'DELETE') {
    const post = await prisma.post.delete({
      where: { id: postId },
    });

    // 削除された記事をJSON形式で返す
    res.json(post);
  } else {
    // HTTPメソッドがDELETE以外の場合は、エラーメッセージを返す
    throw new Error(
      `The HTTP ${req.method} method is not supported at this route.`,
    );
  }
}
```
</div></details>


### Next.jsにDB追加してVercelで公開してみた①終了
お疲れ様です。一旦「⑤投稿データ取得等の処理実装（Prisma Client）」までは完了しましたので、
基本的なブログ投稿機能はこれでできたかと思います。
パート②では認証機能を追加しVercelで公開するとこまでをまとめていきたいと思います
パート②の[Next.jsにDB追加してVercelで公開してみた②記事はこちらから](https://qiita.com/pikimaru/items/3a7aff9dbd052796629a)

## 参考
- [Next.js、Prisma、および PostgreSQL を使用してフルスタック アプリを構築する方法](https://vercel.com/guides/nextjs-prisma-postgres)
- [Next.js､ Prisma､PostgreSQLでフルスタックアプリを作る](https://qiita.com/tomohiko_ohhashi/items/da804ed1f5870c9ce52d)