Next.js + NextAuth.js + Prismaで認証付きアプリケーションの作成

f:id:yun8boo:20210630180911p:plain

こんにちは。フロントエンドエンジニアの渡邉です。 普段はReactとTypeScriptを書いています。

今回は自分がNext.js + NextAuth.js + Prismaを使って認証付きアプリケーションを作成する際の土台を紹介をしようと思います。 フロントエンドエンジニアとしてトレンドの技術を抑えておきたいというのと、実際に新規のプロジェクトで開発する際に採用される可能性もあるので、Next.js + NextAuth.js + Prismaといった選定をしています。

技術の概要

これらの技術を使い実際にアプリケーションを作りながら紹介していきます。

とりあえず最終的なコードが見たい方はこちらをご覧ください。

アプリ作成

TypeScriptでアプリを作成したいので--typescriptフラグをつけています。

npx create-next-app --typescript sample-app

cd sample-app

必要なパッケージのインストール

Prisma
npm install @prisma/client
npm install prisma --save-dev

Prismaのバージョンが2.14以降だと、TypeScript 4.1以上でないと動かないので、該当する場合はTypeScriptのバージョンをあげる必要があります。

Bug: Prisma 2.17.0: Type errors in generated client for UnwrapTuple #5726

NextAuth.js
npm install next-auth @next-auth/prisma-adapter

アプリケーションにNextAuth.jsとPrismaを組み込む

アプリケーションにNextAuth.jsを追加するため、pages/api/authの中に[...nextauth].tsを作成します。

[...nextauth].ts内は以下のように記述します。

import NextAuth from "next-auth"
import Providers from "next-auth/providers"
import { PrismaAdapter } from "@next-auth/prisma-adapter"
import { PrismaClient } from "@prisma/client"

const prisma = new PrismaClient()

export default NextAuth({
  // 1つ以上の認証プロバイダーを構成
  providers: [
    Providers.Google({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
  ],
  adapter: PrismaAdapter(prisma),
})

NextAuthのオプションとしてプロバイダー、アダプター(今回は Prisma )を設定します。これによりNextAuth.jsとPrismaを連携して、アクションがあった際にPrisma経由でユーザーをデータベースに保存しています。

プロバイダーに関しては今回はGoogleを指定し、認証機能を作成しています。 他にも様々な認証プロバイダーを指定できるので使いたいプロバイダーに変えてもらっても構いません。

次に.env.localを作成し、中身にGOOGLE_CLIENT_IDとGOOGLE_CLIENT_SECRETを記載します。

GOOGLE_CLIENT_ID=xxxxxx
GOOGLE_CLIENT_SECRET=yyyyyyy

client idとsecretは https://console.developers.google.com/apis/credentials で作成・取得できます。

ローカルで動作の確認をするために承認済みのリダイレクトURIはhttp://localhost:3000/api/auth/callback/googleと設定しておいてください。

次に、以下のコマンドを実行しPrismaを初期化します。

npx prisma init

.env/prisma/prisma/schema.prismaが作成されます。 prisma/schema.prisma内は以下のように記述します。

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

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model Account {
  id                 String    @id @default(cuid())
  userId             String
  providerType       String
  providerId         String
  providerAccountId  String
  refreshToken       String?
  accessToken        String?
  accessTokenExpires DateTime?
  createdAt          DateTime  @default(now())
  updatedAt          DateTime  @updatedAt
  user               User      @relation(fields: [userId], references: [id])

  @@unique([providerId, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  userId       String
  expires      DateTime
  sessionToken String   @unique
  accessToken  String   @unique
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt
  user         User     @relation(fields: [userId], references: [id])
}

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  emailVerified DateTime?
  image         String?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
  accounts      Account[]
  sessions      Session[]
}

model VerificationRequest {
  id         String   @id @default(cuid())
  identifier String
  token      String   @unique
  expires    DateTime
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt

  @@unique([identifier, token])
}

今回はローカル開発環境で実装しているため、sqliteを採用しています。 他のデータベースを使いたい方は、こちらをご覧ください。

スキーマファイルを作成したら、PrismaCLIを使いPrisma Clientを生成します。

npx prisma generate

最後に次のコマンドを実行し、今回記載したスキーマを元にデータベースを構成します。

npx prisma migrate dev

以上でデータベースと認証機能の設定は終了です。

フロントエンド対応

pages/index.tsx の中身を全部消して、以下のように記述します。

import { signIn, signOut, useSession } from 'next-auth/client'

const IndexPage = () => {
  const [ session, loading ] = useSession()

  if(loading) return null

  return <>
    {!session && <>
      Not signed in <br/>
      <button onClick={() => signIn()}>Sign in</button>
    </>}
    {session && <>
      Signed in as {session.user.email} <br/>
      <button onClick={() => signOut()}>Sign out</button>
    </>}
  </>
}

export default IndexPage

次にpages/_app.tsを作成しNextAuthのProviderでラップします。

import { AppProps } from 'next/app';
import { Provider } from 'next-auth/client'

const App = ({Component, pageProps}: AppProps) => {
  return (
    <Provider session={pageProps.session}>
      <Component {...pageProps} />
    </Provider>
  )
}

export default App

ここでnpm run devしてみると以下のような画面が表示されると思います。

f:id:yun8boo:20210630175717p:plain

Sign inボタンを押し画面に表示されているフローに沿って進めると最終的にサインインしたアカウントのメールアドレスが表示されていると思います。

認証フローの中でSign in with Googleの画面が不用な方は、signIn()の引数にgoogleと渡すことでスキップできます。

import { signIn, signOut, useSession } from 'next-auth/client'

const IndexPage = () => {
  const [ session, loading ] = useSession()

  if(loading) return null

  return <>
    {!session && <>
      Not signed in <br/>
      <button onClick={() => signIn('google')}>Sign in</button> // googleを渡す
    </>}
    {session && <>
      Signed in as {session.user.email} <br/>
      <button onClick={() => signOut()}>Sign out</button>
    </>}
  </>
}

export default IndexPage

これで認証機能の実装が完了です。

サインインしたあとのsessionの中身は以下のようになっています。

{
  user: {
    name: string,
    email: string,
    image: uri
  },
  accessToken: string,
  expires: "YYYY-MM-DDTHH:mm:ss.SSSZ"
}

sessionに追加のデータ(ユーザーのIDなど)を渡したい場合はSession callbackを使います。 [...nextauth].tsを以下のように変更します。

import NextAuth from "next-auth"
import Providers from "next-auth/providers"
import { PrismaAdapter } from "@next-auth/prisma-adapter"
import { PrismaClient } from "@prisma/client"

const prisma = new PrismaClient()

export default NextAuth({
  // 1つ以上の認証プロバイダーを構成
  providers: [
    Providers.Google({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
  ],
  adapter: PrismaAdapter(prisma),
  // ここから下を追加
  callbacks: {
    session: async (session, user) => {
      return Promise.resolve({
        ...session,
        user: {
          ...session.user,
          id: user.id
        }
      });
    }
  }
})

pages/index.tsxのなかのsession.user.emailの部分をsession.user.idに変えて取得できるか確認してみます。

コードを変更すると、今回はTypeScriptで開発しているので、userの中にidは存在しないと、型エラーが発生します。 そのため、types/next-auth.d.tsを作成し、以下のように型を拡張します。

import NextAuth, { DefaultUser } from "next-auth"
import { JWT } from "next-auth/jwt";

declare module "next-auth" {
  interface Session {
    user: User | JWT
  }

  interface User extends DefaultUser {
    id?: string | null
  }
}

こうすることで型エラーが消えて、idも取得できるかと思います。

特定のページで閲覧権限をチェックするため、以下のコンポーネントを作ります。

import { ReactNode } from 'react';
import { signIn, useSession } from 'next-auth/client';

type Props = {
  children?: ReactNode
}

const ProtectedPage = ({children}: Props) => {
  const [session, loading] = useSession()
  if(loading) return null

  if(!loading && !session) {
    return <button  onClick={() => signIn('google')}>Sign in with Google</button>
  }
  return (
    <div>
      {children}
    </div>
  )
}

export default ProtectedPage

サインインしている場合はchildrenを表示し、そうでない場合はサインイン用のボタンを表示します。

仮にIndexページはサインインしている状態でしか見れないとなった場合は以下のように修正します。

import ProtectedPage from '../components/ProtectedPage'

const IndexPage = () => {
  return (
    <ProtectedPage>
      <p>🍭🍭🍭🍭🍭🍭</p>
    </ProtectedPage>
  )
}

export default IndexPage

こうしてあげることで、サインインしてない場合は、以下の画面が表示され f:id:yun8boo:20210630175838p:plain

サインインしている場合は、以下の画面が表示されます。

f:id:yun8boo:20210630175904p:plain

そもそも全ページがサインインしていないと閲覧できない様なアプリケーションの場合は、_app.tsの中でラップしてあげるといいですね。

本番環境にデプロイする際はenv.localにNEXTAUTH_URLを記述します。

NEXTAUTH_URL = https://sample-app.com

加えて、Googleの承認済みのリダイレクトURIを本番用に追加するのを忘れないようにしましょう。

さいごに

Next.jsにNextAuth.jsを使うことで簡単に認証機能の構築ができます。 加えて、Prismaをつかうことで柔軟な型推論や補完がきくので、とても良い開発体験も提供してくれます。

アプリケーションの実装をざっくりと説明したので、詳細まで理解しきれない方もいたと思いますが、まずは全体像が掴めたらいいなと思い記事にしました。

これをきっかけにNext.jsなどを使ったアプリケーションがたくさん開発されると嬉しいです。

スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持っていただいた方は、是非下記の募集ページを御覧ください。