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_USERPOSTGRES_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_IDGOOGLE_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 からいろいろ持ってきます。 signInsignOut は書いている通りのことをしてくれるメソッドです。

signIn メソッドは、何も指定しなければ先ほどみた簡易的なサインインページにリダイレクトします。

ですが、 signIn('google') のようにプロバイダを指定してあげると、直接そのプロバイダのサインインフローに連れて行ってくれます。

useSession hook が一番重要で、ユーザーのセッション情報を含んでいます。

💡

getSession というメソッドもありますが、これは主にサーバー側で使うやつです。サーバー側でユーザーチェックもできるので、 機密情報を含む API Routes などを守ったり、getServerSideProps などでサーバー側で先に認証もできます。

session をログして見ると、アクセストークンやユーザー情報が入っているのが分かります。

あとは、ログインしてるかしてないかで UI を切り替えているだけです。(ログインしていないと sessionnull

次に、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].ts
const 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.ts
import { 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.ts
type 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.tsx
import { 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.tsx
import * 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;
react-query の利点
サーバーステートをローカルに保持しておく必要がなくなる
built-in キャッシュ
stale なデータを裏でアップデートしてくれる
充実した devtools
mutation があった場合は invalidateQueries で refetch 指定可能

その他いろいろ利点はあるんですが、長くなりすぎるので詳しくは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"
}
}

何をしてるかというと、

  1. migrate:deploy script の追加。デプロイするたびにマイグレーションを行うように。(マイグレーションに変更がなければ、スキップします)
  2. buildnpm-run-all でマイグレーションとビルドを走らせるように変更
  3. build-appnext build してるだけ

これで、一度 vercel と打ってみてください。いろいろ聞かれるのを答えていき、ビルドしようとしますが落ちます。なぜかというと、環境変数をセットしていないからです。

💡

Set up and deploy "~"Y と答えてください。その他は Enter 押し続ける、もしくは N です。

ですが、これでプロジェクトが vercel 上で作成されました。

💡

heroku で簡単に postgreSQL db をプロビジョングできるのでお勧めです。

もう一度、同じことを 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 の方でお願いします!ありがとうございました。

© Yuya Oiwa