Oops!!

プログラミングとかのラフなブログ

Next.js + Auth.js(NextAuth.js) + Cloudflare D1でユーザー認証を実装し、Cloudflare Pagesにデプロイする

Next.js + Auth.js(NextAuth.js) + Cloudflare D1でユーザー認証を実装し、Cloudflare Pagesにデプロイする

インフラのサービスが散らばると管理が大変なため、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でリリースするだけです。

プロフィール画像

すずき ゆうた

愛知県でフリーランスのフロントエンド・エンジニアをしています。Reactを用いた開発が得意です。 他にもプロジェクトマネジメントや組織マネジメントも行ってきました。エビデンスのない事でも自分の経験から書いていくので話半分くらいでお願いします。