この記事の目的
- 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 を使用してフルスタック アプリを構築する方法
-
Next.js、 Prisma、PostgreSQLでフルスタックアプリを作る
後者の記事は前者の記事を日本語に翻訳してくれているみたいです。
使用技術と前提
使用技術
- 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
で下画像のように表示されるかと思います
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」を選択しコピー
.envにDBの接続先設定
例としてパスワードがpassword1234
の場合のDATABASE_URLを記載しています
※パスワードはプロジェクト作成時に記入したものになります。
DATABASE_URL="postgresql://postgres:password1234@db.vnsuouoysyhgsevojusk.supabase.co:5432/postgres"
④DB設定(schema.prismaでテーブル作成)
必要なテーブル定義をschema.prisma
に下記のように記載していきます。
Account
やUser
のカラムについては認証で必要になるためこのようになっています
// 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の画面でも下のようにテーブルが作成されているのが確認できるかと思います
【補足】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
を開くと下の画像のようにテーブルが追加されているかと思います。
自分はデータを追加しているのでデータが入っていますが、実際はまだ入っていないかと思います
⑤投稿データ取得等の処理実装(Prisma Client)
まず基本的なデータ取得をPrisma Client
を使用して実装していきます
Prisma Client をインストール
npm install @prisma/client
Prisma Client を生成
npx prisma generate
Prismaクライアントへの接続部分を作成
lib/prisma.ts
を作成しPrismaクライアントへの接続部分を実装する
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コードをクリックして全て表示
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コードをクリックして全て表示
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コードをクリックして全て表示
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コードをクリックして全て表示
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コードをクリックして全て表示
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コードをクリックして全て表示
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コードをクリックして全て表示
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で公開してみた②記事はこちらから