34
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-04-23

この記事の目的

  • 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(バージョンは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で公開してみる

※長くなるためこの記事では①〜⑤までをまとめています。⑥以降はこちらの記事でまとめています
※バックエンドを実装してVercel公開できることを目的としているので、コードは載せていますが細かい説明せずにさらっと説明しているので、ソースコード全体についてはこちらのGitHubを参考にして下さい。

①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

prismaインストール

prismaインストール

npm install prisma --save-dev

prisma初期化

npx prisma init

初期化すると以下のファイルが生成される

  • env
  • prisma/schema.prisma
    .envにはDBの接続先設定。schema.prismaに使用するデータベースとテーブル設定。
    .envは③で設定の説明をしています
    schema.prismaは④で設定の説明をしています

③supabaseの設定

supabaseでプロジェクト作成

こちらについては下記の「手順3」まで行って下さい
Prisma で使用する無料の PostgreSQL データベースを Supabase にセットアップする
もしくは下記の記事の「プロジェクト名等の入力・選択」まで行って下さい
SupabaseでPostgreSQLのDB作成

PostgreSQLデータベースの接続URLの設定

プロジェクト作成後「Project Setting」->「Database」->「Connection string」->「URI」を選択しコピー
スクリーンショット 2023-04-20 22.34.45.png

.envにDBの接続先設定

例としてパスワードがpassword1234の場合のDATABASE_URLを記載しています
※パスワードはプロジェクト作成時に記入したものになります。

.env
DATABASE_URL="postgresql://postgres:password1234@db.vnsuouoysyhgsevojusk.supabase.co:5432/postgres"

④DB設定(schema.prismaでテーブル作成)

必要なテーブル定義をschema.prismaに下記のように記載していきます。
AccountUserのカラムについては認証で必要になるためこのようになっています

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

【補足】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

⑤投稿データ取得等の処理実装(Prisma Client)

まず基本的なデータ取得をPrisma Clientを使用して実装していきます

Prisma Client をインストール

npm install @prisma/client

Prisma Client を生成

npx prisma generate

Prismaクライアントへの接続部分を作成

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

lib/prisma.tsコードをクリックして全て表示
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;

公開されたすべての投稿を取得する処理の実装

pages/index.tsxを作成し投稿を取得する処理の実装をする
libディレクトリを作成し、配下にprisma.tsを作成して下さい

pages/index.tsxコードをクリックして全て表示
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

その他のブログ機能作成(新規投稿/下書き/公開/指定IDの投稿取得/削除)

新規投稿

pages/create.tsxを作成
pages/api/post/index.tsを作成

pages/create.tsxコードをクリックして全て表示
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;
pages/api/post/index.tsコードをクリックして全て表示
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);
}

下書き機能

pages/drafts.tsxを作成

pages/drafts.tsxコードをクリックして全て表示
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;

公開機能

pages/api/publish/[id].tsを作成
pages/p/[id].tsxを作成

pages/api/publish/[id].tsコードをクリックして全て表示
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);
}

pages/p/[id].tsxコードをクリックして全て表示
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;

指定IDの投稿取得/削除機能

pages/api/post/[id].tsを作成

pages/api/post/[id].tsコードをクリックして全て表示
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.`,
    );
  }
}

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

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

参考

34
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
34
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?