Next.js + Prisma + NextAuth.js + React Query で作るフルスタックアプリケーションの新時代
追記 こちらのスタックを用いて NPS / フィードバック分析プラットフォームをリリースしました。よければ試してみてください! https://loggnog.com
この記事は Next.js とは何かを知っている前提として書いています。
どうも、@yuyaaar です。
最近は Next.js アプリを見ることが多くなってきました。もはや JAM スタックの王道、と言っても過言ではないかもしれません。
ですが、やっぱりフルスタックとなると、データベースや認証などが必要になってきて、その辺のやり方がいまいちよくわからない、という人も多いのではないでしょうか。
自分もその一人でした。😅
いろいろ調べたり作ったりした結果、今現在もっとも最強コンビであろう、
でのフルスタックアプリケーションの作り方をこの記事では書いていきます。
今回は、チュートリアルアプリでよくある Todo アプリを作って、vercel にデプロイ、というのをやってみたいと思います。
まずは最初に Next.js ボイラープレートアプリを作りましょう。
$ npx create-next-app {appName}
作成できたら、まずは TypeScript アプリにしたいので、とりあえず pages/
直下の js ファイルを ts, tsx に変更してください。
そしたら一度 yarn dev
をしてみましょう。Next.js が react などの type definition をインストールしろと出るので、します。
もう一度 yarn dev
をすると、Next.js が自動的に tsconfig.json
を作ってくれます。便利。
次は Prisma が接続するローカルデータベースを用意しましょう。このチュートリアルでは PostgreSQL を使用しますが、sqlite, mysql と sqlserver をデータベースとして使用することが可能です(2020/12/23 時点)。
Docker を使って、さくっと作ってしまいましょう。Docker をインストールしてない場合はこちらからダウンロードしてください。
プロジェクトルートに、
$ touch docker-compose.yml
version: "3.8"services: db: image: "postgres:12" ports: - "54320:5432" volumes: - ./pgdata:/var/lib/postgresql/data environment: - POSTGRES_USER={appName} - POSTGRES_PASSWORD=admin - POSTGRES_DB={appName}
POSTGRES_USER
と POSTGRES_DB
とかはプロジェクト名とかなんでもいいです。
あとは、docker-compose up
と打つだけで、PostgreSQL が立ち上がり、接続可能となりました。
pgdata/
というファイルをローカルに作成するので、そいつは .gitignore
に入れておきましょう。ローカルでしか使われません。
次は、prisma をインストールしましょう。
prisma を簡単に説明すると、Node.js 用のタイプセーフな ORM です。凄さは後ほど
$ yarn add @prisma/cli --dev$ yarn add @prisma/client
そしてインストールし終わったら、
$ npx prisma init
これでルートディレクトリに prisma/
フォルダと .env
ファイルが作成されました。(env ファイルを .gitignore
忘れずに)
.env
ファイル内の DATABASE_URL
を、先ほど作成したデータベースの URL に変更します。
URLはこんな感じ postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}
@localhost:54320/{POSTGRES_USER}?schema=public
ちなみに、デフォルトで作成されるやつは
localhost:5432
ですが localhost:54320
に変更を忘れなく
次に、NextAuth.js をインストールします。
簡単に説明すると、Next.js の API Route を用いて、めっちゃ簡単に認証機能を入れることができるやつです。ソーシャル認証も可能。
$ yarn add next-auth
NextAuth.js は pages/api/auth/[...nextauth].ts
ファイルをエンドポイントとして見るので、作りましょう。間違えのないように。
import NextAuth from "next-auth";import Providers from "next-auth/providers";import Adapters from "next-auth/adapters";import { PrismaClient } from "@prisma/client";let prisma;// ローカルでは大量にデータベースコネクションを張ってしまうことがあるので、// このようなアプローチをとる。TypeScript が global type に prisma がないと怒るので、// ルートディレクトリに global.d.ts を作成し、// export {};// declare global {// namespace NodeJS {// interface Global {// prisma: any;// }// }// }// としてあげれば治るif (process.env.NODE_ENV === "production") { prisma = new PrismaClient();} else { if (!global.prisma) { global.prisma = new PrismaClient(); } prisma = global.prisma;}const options = { providers: [ Providers.Google({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, }), // プロバイダーは何個でも指定できる。 // https://next-auth.js.org/getting-started/introduction で一覧がみれる // 例: // Providers.Twitter({ // clientId: process.env.TWITTER_CLIENT_ID, // clientSecret: process.env.TWITTER_CLIENT_SECRET, // }), ], adapter: Adapters.Prisma.Adapter({ prisma }),};export default (req, res) => NextAuth(req, res, options);
何をしてるかというと、options
にプロバイダ一覧を渡して、prisma
をアダプターとして指定しているだけです。
これでうまいこと NextAuth.js が prisma と連携して、サインインなどのアクションがあった場合、prisma 経由でユーザーをデータベースに保存してくれます。
今回は Google をプロバイダとして、認証機能を作りました。
GOOGLE_CLIENT_ID
と GOOGLE_CLIENT_SECRET
はデベロッパーコンソールからゲッして、.env
ファイルに保存してください。
承認済みのリダイレクト URI は、http://localhost:3000/api/auth/callback/google
と設定してください。
それに加えて、NextAuth.js の規程で NEXTAUTH_URL
の環境変数も .env
に追加してください。値は、canonical URL of the website なので、ローカルの場合は http://localhost:3000
となります。
後ほど、デプロイ時の環境変数の設定の仕方も書きます。
次に、NextAuth.js が認証したユーザー情報を保持するスキーマを prisma/schema.prisma
に貼ります。
model Account { id Int @id @default(autoincrement()) compoundId String @unique @map(name: "compound_id") userId Int @map(name: "user_id") providerType String @map(name: "provider_type") providerId String @map(name: "provider_id") providerAccountId String @map(name: "provider_account_id") refreshToken String? @map(name: "refresh_token") accessToken String? @map(name: "access_token") accessTokenExpires DateTime? @map(name: "access_token_expires") createdAt DateTime @default(now()) @map(name: "created_at") updatedAt DateTime @default(now()) @map(name: "updated_at") @@index([providerAccountId], name: "providerAccountId") @@index([providerId], name: "providerId") @@index([userId], name: "userId") @@map(name: "accounts")}model Session { id Int @id @default(autoincrement()) userId Int @map(name: "user_id") expires DateTime sessionToken String @unique @map(name: "session_token") accessToken String @unique @map(name: "access_token") createdAt DateTime @default(now()) @map(name: "created_at") updatedAt DateTime @default(now()) @map(name: "updated_at") @@map(name: "sessions")}model User { id Int @id @default(autoincrement()) name String? email String? @unique emailVerified DateTime? @map(name: "email_verified") image String? createdAt DateTime @default(now()) @map(name: "created_at") updatedAt DateTime @default(now()) @map(name: "updated_at") @@map(name: "users")}model VerificationRequest { id Int @id @default(autoincrement()) identifier String token String @unique expires DateTime createdAt DateTime @default(now()) @map(name: "created_at") updatedAt DateTime @default(now()) @map(name: "updated_at") @@map(name: "verification_requests")}
お疲れ様です。これでデータベースと認証機能の設定は終了です。
最後にマイグレーションをローカルデータベースにしましょう。
$ npx prisma migrate dev --preview-feature
ではフロントエンドに移ります。
index.tsx
の中身を全部消して、以下をペーストしてください。
import { signIn, signOut, useSession } from "next-auth/client";export default function Home() { const [session, loading] = useSession(); return ( <> {!session && ( <> サインインしてください。 <br /> <button onClick={signIn}>Sign in</button> </> )} {session && ( <> サインイン完了。 email: {session.user.email} <br /> <button onClick={signOut}>Sign out</button> </> )} </> );}
そして、 _app.tsx
に NextAuth.js の <Provider />
でラップしましょう。
import { Provider } from "next-auth/client";export default function App({ Component, pageProps }) { return ( <Provider session={pageProps.session}> <Component {...pageProps} /> </Provider> );}
これで一度 dev サーバーを起動してみてください。このような画面が見えるはずです。
これでサインインボタンを押すと、NextAuth.js が指定したプロバイダでログインページを自動で作ってくれているのが分かります。
postgreSQL を docker で立ち上げるのを忘れずに!
あとはプロバイダーフローを進めていき、成功すると以下のような画面になります。
これでセキュアな認証機能の実装ができました。
ユーザーが作成できてるかどうか確認するには、 prisma が提供する prisma studio
(GUI データベースエディタ)で確認するのをお勧めします。
ターミナルで別タブを開き、
$ npx prisma studio
すると localhost:5555
に接続し、以下のような画面に移ります。
ちゃんとユーザーとしてデータベースに保存されてますね。
ここまでのフローで、何がどうなったかよくわからないかもしれないので、解説していきます。
import { signIn, signOut, useSession } from "next-auth/client";export default function Home() { const [session, loading] = useSession(); return ...}
まずここで、 next-auth/client
からいろいろ持ってきます。 signIn
と signOut
は書いている通りのことをしてくれるメソッドです。
signIn
メソッドは、何も指定しなければ先ほどみた簡易的なサインインページにリダイレクトします。
ですが、 signIn('google')
のようにプロバイダを指定してあげると、直接そのプロバイダのサインインフローに連れて行ってくれます。
useSession
hook が一番重要で、ユーザーのセッション情報を含んでいます。
getSession
というメソッドもありますが、これは主にサーバー側で使うやつです。サーバー側でユーザーチェックもできるので、
機密情報を含む API Routes などを守ったり、getServerSideProps
などでサーバー側で先に認証もできます。
session
をログして見ると、アクセストークンやユーザー情報が入っているのが分かります。
あとは、ログインしてるかしてないかで UI を切り替えているだけです。(ログインしていないと session
は null
)
次に、Todo List 用のモデルを作成しましょう。
schema.prisma
に以下を追加してください。
model User {
...省略
Todo Todo[]
}
model Todo {
id Int @id @default(autoincrement())
body String
title String
createdAt DateTime @default(now())
User User? @relation(fields: [userId], references: [id])
userId Int?
}
そして、マイグレーション
$ npx prisma migrate dev --preview-feature
以上です。次は Todo アプリの UI と、ユーザーの Todo List を取得する API を作っていきましょう。
まずは、session
の情報にユーザー ID を追加しましょう。今のところ、 session.user
には google oauth からの情報しか入っていないので。
// pages/api/auth/[...nextauth].tsconst options = { providers: [ Providers.Google({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, }), ], adapter: Adapters.Prisma.Adapter({ prisma }), callbacks: { session: async (session, user) => { // user はデータベースに保存されている user オブジェクト session.user.id = user.id; return Promise.resolve(session); }, },};
pages/api/todos.ts
API を作成しましょう。(graphQL サーバーがいい!という人はこちら)
// pages/api/todos.tsimport { NextApiRequest, NextApiResponse } from "next";import { PrismaClient } from "@prisma/client";import { getSession } from "next-auth/client";const prisma = new PrismaClient();export default async (req: NextApiRequest, res: NextApiResponse) => { const session = await getSession({ req }); if (!session) return res.status(401).end("Please log in to view"); const userId = session.user.id; if (req.method === "GET") { const todos = await prisma.todo.findMany({ orderBy: { createdAt: "desc", }, where: { userId, }, }); return res.status(200).json(todos); }};
先ほど session に加えた user.id
の情報は、
if (!session) return res.status(401).end("Please log in to view"); const userId = session.user.id; if (req.method === "GET") { const todos = ... }
で使用しています。わざわざデータベースにユーザー情報を取得しなくていいという利点ですね。
prisma の凄いところは、 schema に合わせて typesafe なところです。
await prisma...
のところを編集してみてください。
ちゃんとモデルの情報が type として認識されており、リレーションを取得するのも簡単です。
なぜこれができるかというと、 migrate
を走らせるたびに裏では prisma generate
がマイグレーション後に走っており、 node_modules/@prisma/client
に型情報として残るからです。
保存して、ログインした状態で /api/todos
を URL で叩いてみてください。
何もない配列が返ってこれば、成功です。
ログアウトして同じ URL を叩くと、 Please log in to view
と出るはずです。
今のところ GET
リクエストだけなので、新規作成用に POST
も作りましょう。
// pages/api/todos.tstype Data = { title: string; body: string;};export default async (req: NextApiRequest, res: NextApiResponse) => { if (req.method === "GET") { ... } if (req.method === "POST") { const { title, body } = JSON.parse(req.body) as Data; const createdTodo = await prisma.todo.create({ data: { title, body, User: { connect: { id: userId, }, }, }, }); res.status(201).json(createdTodo); }};
最後に Todo List を表示、新規作成する UI を作りましょう。
まずは react-query をインストール
$ yarn add react-query
そして、 pages/_app.tsx
に react-query の <Provider />
でラップ
import "../styles/globals.css";import { Provider } from "next-auth/client";import { QueryClient, QueryClientProvider } from "react-query";const queryClient = new QueryClient();function MyApp({ Component, pageProps }) { return ( <QueryClientProvider client={queryClient}> <Provider session={pageProps.session}> <Component {...pageProps} /> </Provider> </QueryClientProvider> );}export default MyApp;
次に、 /components
をルートディレクトリで作成して、以下のコンポーネントを追加してください。
// components/TodoList.tsximport { Todo } from "@prisma/client";import * as React from "react";import { useQuery } from "react-query";const TodoList = () => { const { data: todos, isLoading } = useQuery<Todo[]>("todos", async () => { const res = await fetch("/api/todos"); return res.json(); }); if (isLoading) return <span>loading...</span>; if (todos.length === 0) return <span>no todos</span>; return ( <ul> {todos.map((todo) => ( <li key={todo.id}> <h2>{todo.title}</h2> <span>{todo.body}</span> </li> ))} </ul> );};export default TodoList;
// components/NewTodoForm.tsximport * as React from "react";import { useMutation, useQueryClient } from "react-query";const NewTodoForm = () => { const queryClient = useQueryClient(); const [form, update] = React.useState({ title: "", body: "", }); const { mutate } = useMutation( () => { return fetch("/api/todos", { method: "POST", body: JSON.stringify(form), }); }, { onSuccess: () => { queryClient.invalidateQueries("todos"); }, } ); const saveTodo = (ev: React.FormEvent<HTMLFormElement>) => { ev.preventDefault(); update({ title: "", body: "" }); mutate(); }; return ( <form onSubmit={saveTodo} style={{ display: "flex", flexDirection: "column", }} > <label style={{ display: "flex", flexDirection: "column", }} > タイトル <input style={{ width: "500px", }} type="text" id="title" value={form.title} onChange={(e) => update({ ...form, title: e.target.value })} /> </label> <label style={{ display: "flex", flexDirection: "column", marginTop: "16px", }} > 内容 <textarea style={{ width: "500px", }} id="body" value={form.body} onChange={(e) => update({ ...form, body: e.target.value })} /> </label> <button style={{ width: "100px", marginTop: "16px", }} > Save </button> </form> );};export default NewTodoForm;
その他いろいろ利点はあるんですが、長くなりすぎるので詳しくはofficial docsで
pages/index.tsx
に入れます
import TodoList from "../components/TodoList";import NewTodoForm from "../components/NewTodoForm";{ session && ( <> サインイン完了。 email: {session.user.email} <br /> <button onClick={signOut}>Sign out</button> <NewTodoForm /> <TodoList /> </> );}
こういう画面になると思います(UI はよくないのでその辺はカスタマイズしてください。)
適当にタイトルと内容を入れて Save
を押すと、すぐ反映されるはずです。
Deployment
とりあえずこの辺でアプリは完成なので、デプロイしてきましょう。
デプロイプラットフォームは vercel を使います。
まず vercel CLI をインストールして、アカウント作成・ログインを行ってください。
そして、package.json
をこのように変更を加えます
{ "name": "ts-prisma-nextauth-tutorial", "version": "0.1.0", "private": true, "scripts": { "migrate:deploy": "prisma migrate deploy --preview-feature", "dev": "next dev", "build": "npm-run-all migrate:deploy build-app", "build-app": "next build", "start": "next start" }, "dependencies": { "@prisma/client": "^2.13.1", "next": "10.0.4", "next-auth": "^3.1.0", "react": "17.0.1", "react-dom": "17.0.1", "react-query": "^3.5.4" }, "devDependencies": { "@prisma/cli": "^2.13.1", "@types/node": "^14.14.14", "@types/react": "^17.0.0", "npm-run-all": "^4.1.5", "typescript": "^4.1.3" }}
何をしてるかというと、
migrate:deploy
script の追加。デプロイするたびにマイグレーションを行うように。(マイグレーションに変更がなければ、スキップします)build
をnpm-run-all
でマイグレーションとビルドを走らせるように変更build-app
はnext build
してるだけ
これで、一度 vercel
と打ってみてください。いろいろ聞かれるのを答えていき、ビルドしようとしますが落ちます。なぜかというと、環境変数をセットしていないからです。
Set up and deploy "~"
は Y
と答えてください。その他は Enter
押し続ける、もしくは N
です。
ですが、これでプロジェクトが vercel 上で作成されました。
https://vercel.com/${username}/${projectName}
にいくと、settings
タブがあるので、押します。
- Environment Variable をクリック
Which type of Environment Variable do you want to add? で
plaintext
を選択What's its name and value? で name を
DATABASE_URL
、 value を database URL
heroku で簡単に postgreSQL db をプロビジョングできるのでお勧めです。
In which Environments would you like to make it available? で Development (Local) 以外を選択
Save
もう一度、同じことを NEXTAUTH_URL
用に作ります。
手順は同じですが、What's its name and value? だけ、 name を NEXTAUTH_URL
, value を production の canonical URL
ex. ${projectName}.${username}.vercel.app
-> ts-prisma-nextauth-tutorial.just-cheese.vercel.app
みたいな。ドメインがあれば、それにしてください。
最後に、Google Developer Console で Callback URL に ↑ + /api/auth/callback/google
を追加してください。
https://
をつけ忘れずに!
これでデプロイ手順は終わりです!あとはもう一度 vercel
と入力してデプロイを待てば、成功するはず!!です。
成功したら、ドメインのランダム英数字が入っていないやつをクリックしてください。
以上が、今僕が現段階で思う最強フルスタックアプリの作成法でした。
コメントや意見は twitter の方でお願いします!ありがとうございました。