インフラのサービスが散らばると管理が大変なため、Cloudflareに統一したいという理由と、最近ではデータベース系のサービスに気楽に使える無料プランが減っておりD1を使いたいという理由でCloudflareにNext.jsで作ったユーザー認証をデプロイしたいと思い作ってみました。
今回使ったのは以下です。
- Next.js
- Auth.js(NextAuth.js)
- Cloudflare D1
- Cloudflare Workers & Pages
環境構築
メインプロジェクト
今回プロジェクトはnext-auth-app
という名前で作ります。
Cloudflareにログインしている状態で、以下のコマンドを実行するとNext.jsの環境とCloudflareにデフォルトのNext.jsがデプロイされます。
npm create cloudflare@latest next-auth-app -- --framework=next
今回は以下のようにNext.jsのプロジェクトを生成しました。
デプロイも行われていると思うので、Cloudflareでも確認してみます。
こんな感じになっていたら大丈夫です。
D1の作成
次はデータベースの構築をします。ここではnext-auth-app-db
という名前にします。
まずはターミナルでプロジェクト配下に移動して、データベースを構築します。
npx wrangler d1 create next-auth-app-db
成功すると以下のような結果が表示されると思うので、これをwrangler.toml
にコピペします。コメントアウトされてる部分があるので、そこに上書きする感じになります。
✅ Successfully created DB 'next-auth-app-db' in region APAC
Created your new D1 database.
[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "next-auth-app-db"
database_id = "xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx"
こちらもCloudflareの管理画面でD1を確認してあればOKです。
インストール
next-authのbeta版とD1用のアダプターを使います。
npm i next-auth@beta @auth/d1-adapter
記述がわかりやすいためPrismaのschemaを利用して、SQLの生成を行います。
npm i -D prisma
npx prisma init --datasource-provider sqlite
ローカル開発用の環境ファイルを作成
Cloudflareでは.envが利用できないため、開発環境では.dev.varsというファイルを利用します。ただし.envはローカル環境では通常のNext.jsとして機能します。
touch .dev.vars
環境変数の設定
Googleの認証情報のやり方はいろんな人が記事にしているので割愛します。
AUTH_SECRETは公式にあるようにopenssl rand -base64 32
で生成し、以下のように設定しました。
AUTH_SECRET="xxx"
AUTH_GOOGLE_ID="xxx"
AUTH_GOOGLE_SECRET="xxx"
Envの型の生成
env.d.tsに空のCloudflareEnvというinterfaceがありますが、以下のコマンドで自動で型を生成してくれます。
npm run cf-typegen
schemaの設定とSQLの生成
データベースの構成に関してはまだBeta版を利用しているので、変更される可能性があります。
Schemaの設定
schema.prismaに、User、Account、Session、VerificationTokenモデルを作成します。
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
@@map("users")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
oauth_token String?
oauth_token_secret String?
scope String?
id_token String?
session_state String?
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
sessionToken String
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
@@map("sessions")
}
model VerificationToken {
identifier String
token String
expires DateTime
@@unique([identifier, token])
@@map("verificationtokens")
}
マイグレーション用のSQLを生成するスクリプト
こちらのスクリプトはChatGPTに生成してもらいました。こういうダルい作業をやってくれるのめちゃくちゃ便利です。
こちらはプロジェクトのrootにmigration.js
を作ります。
やっている内容としてはnode_modules内に生成された.prismaのschemaとプロジェクトのschemaの差分を見て、PrismaにてSQLを生成しています。
// migration.js
const { exec } = require('child_process');
const fs = require('fs');
const path = require('path');
const util = require('util');
const execPromise = util.promisify(exec);
// 引数からマイグレートファイル名を取得
const migrateFileName = process.argv[2];
if (!migrateFileName) {
console.error('Error: マイグレートファイル名を指定してください');
process.exit(1);
}
// package.jsonから設定を取得
const packageJsonPath = path.join(__dirname, 'package.json');
const packageJsonContent = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
const d1Config = packageJsonContent.cloudflare.d1;
const databaseName = d1Config.name;
const fromSchema = d1Config["--from-schema-datamodel"];
const toSchema = d1Config["--to-schema-datamodel"];
if (!databaseName || !fromSchema || !toSchema) {
console.error('Error: package.jsonから必要な設定を取得できませんでした');
process.exit(1);
}
// migrationsディレクトリ
const migrationsDir = path.join(__dirname, 'migrations');
// migrationsディレクトリが存在しない場合は作成
if (!fs.existsSync(migrationsDir)) {
fs.mkdirSync(migrationsDir);
}
// 既存のマイグレートファイルを確認
const files = fs.readdirSync(migrationsDir);
const existingFile = files.find(file => file.includes(migrateFileName));
async function generateMigration() {
if (existingFile) {
console.log(`マイグレートファイル "${existingFile}" はすでに存在します。生成をスキップします。`);
return existingFile;
} else {
// マイグレートファイルの生成
await execPromise(`npx wrangler d1 migrations create ${databaseName} ${migrateFileName}`);
// 再度ファイルリストを取得して生成されたファイルを特定
const updatedFiles = fs.readdirSync(migrationsDir);
const newMigrateFile = updatedFiles.find(file => file.includes(migrateFileName));
if (!newMigrateFile) {
throw new Error('生成されたマイグレートファイルが見つかりません');
}
console.log(`新しいマイグレートファイル "${newMigrateFile}" を生成しました。`);
return newMigrateFile;
}
}
generateMigration().then(async migrateFile => {
const sqlFilePath = path.join(migrationsDir, migrateFile);
// fromSchemaが存在するかチェック
const fromSchemaPath = path.join(__dirname, fromSchema);
const fromSchemaOption = fs.existsSync(fromSchemaPath) ? `--from-schema-datamodel ${fromSchema}` : '--from-empty';
await execPromise(`npx prisma migrate diff ${fromSchemaOption} --to-schema-datamodel ${toSchema} --script > ${sqlFilePath}`);
console.log('マイグレートファイルの生成とSQL文書き込みが完了しました');
await execPromise(`npx prisma generate`);
console.log('Prisma Clientの型を生成しました');
}).catch(error => {
console.error('Error:', error);
process.exit(1);
});
こちらのスクリプトはpackage.json
に設定をするようにしてるので、追加します。
{
"name": "next-auth-app",
〜 省略 〜
"cloudflare": {
"d1": {
"name": "next-auth-app-db",
"--from-schema-datamodel": "./node_modules/.prisma/client/schema.prisma",
"--to-schema-datamodel": "./prisma/schema.prisma"
}
}
}
コマンドの実行
以下のコマンドを実行するとmigrationsフォルダが作成され、0001_create_auth_user_tables.sqlというファイルが生成されます。
node migration.js create_auth_user_tables
このコマンドはnpm scriptsにした方が便利かと思います。
マイグレート
ローカル環境のD1にマイグレートしてテーブルを生成します。
npx wrangler d1 migrations apply next-auth-app-db --local
コーディング
ファイルの作成
mkdir -p src/app/api/auth/[...nextauth] src/utils src/components/BtnAuth src/components/User
touch src/app/api/auth/[...nextauth]/route.ts src/utils/auth.ts src/components/BtnAuth/BtnAuth.tsx src/components/User/ClientSideUser.tsx src/components/User/ServerSideUser.tsx
src/utils/auth.ts
getRequestContext
はruntimeがedgeの場合のみ使え、Contextを通して環境変数などを取得することができます。
import NextAuth from "next-auth"
import type { NextRequest } from "next/server"
import { D1Adapter } from "@auth/d1-adapter"
import Google from "next-auth/providers/google"
import type { NextAuthConfig } from "next-auth"
import { getRequestContext } from "@cloudflare/next-on-pages"
type Config = (request: NextRequest | undefined) => NextAuthConfig
const config: Config = () => {
const { env } = getRequestContext();
return {
adapter: D1Adapter(env.DB),
providers: [
Google({
clientId: env.AUTH_GOOGLE_ID,
clientSecret: env.AUTH_GOOGLE_SECRET
}),
],
secret: env.AUTH_SECRET,
debug: process.env.NODE_ENV !== "production" ? true : false,
}
}
export const { handlers, auth, signIn, signOut } = NextAuth(config)
src/app/api/auth/[...nextauth]/route.ts
こちらでは通常の書き方に加えてexport const runtime = "edge";
を追加します。
import { handlers } from "@/utils/auth";
export const { GET, POST } = handlers;
export const runtime = "edge";
src/components/BtnAuth/BtnAuth.tsx
ログイン、ログアウトのボタンです。
"use client"
import { signIn, signOut } from "next-auth/react"
export const BtnSignIn = () => <button onClick={() => signIn("google")}>SIGN IN</button>
export const BtnSignOut = () => <button onClick={() => signOut()}>SIGN OUT</button>
src/components/User/ClientSideUser.tsx
フロントエンドでユーザー情報を取得するコンポーネントです。
"use client"
import { useSession } from 'next-auth/react';
export const ClientSideUser = () => {
const session = useSession()
return <div>ClientSide: {session.data?.user?.name}</div>
}
src/components/User/ServerSideUser.tsx
バックエンドでユーザー情報を取得するコンポーネントです。
import { auth } from "@/utils/auth"
export const ServerSideUser = async () => {
const session = await auth()
return <div>ServerSide: {session?.user?.name}</div>
}
src/app/layout.tsx
layout.tsxではSessionProviderを追加します。
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { SessionProvider } from 'next-auth/react';
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<SessionProvider>
<body className={inter.className}>{children}</body>
</SessionProvider>
</html>
);
}
src/app/page.tsx
page.tsxもexport const runtime = "edge"
を付けています。これはgetRequestContext
を使用しているため、edgeで動く必要があるためです。
import { BtnSignIn, BtnSignOut } from "@/components/BtnAuth/BtnAuth"
import { ClientSideUser } from "@/components/User/ClientSideUser"
import { ServerSideUser } from "@/components/User/ServerSideUser"
export default function Page() {
return (
<main>
<h1>Next.js + Auth.js + Cloudflare D1</h1>
<div>
<BtnSignIn />
</div>
<div>
<BtnSignOut />
</div>
<ClientSideUser />
<ServerSideUser />
</main>
);
}
export const runtime = "edge"
デプロイについて
マイグレート
デプロイに際して本番のD1に対してマイグレートします。
npx wrangler d1 migrations apply next-auth-app-db --remote
wrangler.tomlにはproductionの設定を追加する必要があります。
wrangler.toml
######## PRODUCTION environment config ########
[env.production.vars]
AUTH_SECRET="xxx"
AUTH_GOOGLE_ID="xxx"
AUTH_GOOGLE_SECRET="xxx"
[[env.production.d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "next-auth-app-db"
database_id = "xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx"
あとはnpm run deploy
でリリースするだけです。