こんにちは。フロントエンドエンジニアの渡邉です。 普段はReactとTypeScriptを書いています。
今回は自分がNext.js + NextAuth.js + Prismaを使って認証付きアプリケーションを作成する際の土台を紹介をしようと思います。 フロントエンドエンジニアとしてトレンドの技術を抑えておきたいというのと、実際に新規のプロジェクトで開発する際に採用される可能性もあるので、Next.js + NextAuth.js + Prismaといった選定をしています。
技術の概要
- Framework: Next.js
- Database / ORM: Prisma
- Authentication: NextAuth.js
これらの技術を使い実際にアプリケーションを作りながら紹介していきます。
とりあえず最終的なコードが見たい方はこちらをご覧ください。
アプリ作成
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
してみると以下のような画面が表示されると思います。
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
こうしてあげることで、サインインしてない場合は、以下の画面が表示され
サインインしている場合は、以下の画面が表示されます。
そもそも全ページがサインインしていないと閲覧できない様なアプリケーションの場合は、_app.ts
の中でラップしてあげるといいですね。
本番環境にデプロイする際はenv.local
にNEXTAUTH_URLを記述します。
NEXTAUTH_URL = https://sample-app.com
加えて、Googleの承認済みのリダイレクトURIを本番用に追加するのを忘れないようにしましょう。
さいごに
Next.jsにNextAuth.jsを使うことで簡単に認証機能の構築ができます。 加えて、Prismaをつかうことで柔軟な型推論や補完がきくので、とても良い開発体験も提供してくれます。
アプリケーションの実装をざっくりと説明したので、詳細まで理解しきれない方もいたと思いますが、まずは全体像が掴めたらいいなと思い記事にしました。
これをきっかけにNext.jsなどを使ったアプリケーションがたくさん開発されると嬉しいです。
スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持っていただいた方は、是非下記の募集ページを御覧ください。