🔥

Next.jsのRoute HandlersでHonoを使いつつAuth.jsで認証してCloudflare PagesにDeployしたい

2023/10/28に公開

はじめに

はじめまして、yu7400ki です。
ちょっとした好奇心から、Next.js + Hono + Auth.js を試してみたので、記事として残しておこうと思います。

Next.js

https://nextjs.org/

言わずとしれた React のフレームワークです。
今回は App Router を使って書いていきます。
Route Handlers は Pages Router の API Routes と同等の機能を持ちます。

Hono

https://hono.dev/

今最も熱い 🔥 と言っても過言ではない(かもしれない)、イケてる Web フレームワークです。
その特徴はなんといっても、様々なランタイムで動くこと。
ほんとに色々対応しているので、ぜひドキュメントをご確認ください。
今回は Route Handlers の中で Hono を使います。

Auth.js

https://authjs.dev/

以前までNextAuth.jsと呼ばれていた認証ライブラリです。
Next.js だけでなく SvelteKit や SolidStart などにも対応するようになりました。
エッジランタイムでも動作します。
ただし、現時点ではベータ版という位置づけです。

Cloudflare Pages

https://www.cloudflare.com/ja-jp/developer-platform/pages/

Cloudflare が提供する「フロントエンド開発者のためのフルスタックプラットフォーム」らしいです。
Functions を利用することで、なんかうまいことエッジで動くっぽいです。
ここまでランタイムの話に触れていたのは、最終的にエッジで動かすためです。

環境構築

1. セットアップ

https://developers.cloudflare.com/pages/framework-guides/deploy-a-nextjs-site/#use-the-edge-runtime

Cloudflare Pages で動かすための Next.js のプロジェクトをセットアップします。

npm create cloudflare@latest my-next-app -- --framework=next

指示に従ってお好きなように設定してください。
TypeScript と App Router を使用することを推奨します。
デプロイはちょっと時間がかかるので、後回しでも大丈夫です。

出力
Need to install the following packages:
create-cloudflare@2.6.1
Ok to proceed? (y)

using create-cloudflare version 2.6.1

╭ Create an application with Cloudflare Step 1 of 3
│
├ In which directory do you want to create your application?
│ dir ./my-next-app
│
├ What type of application do you want to create?
│ type Website or web app
│
├ Which development framework do you want to use?
│ framework Next
│
╰ Continue with Next via `npx create-next-app@13.4.19 my-next-app`

Need to install the following packages:
create-next-app@13.4.19
Ok to proceed? (y)
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias? … No / Yes
Creating a new Next.js app in

Using npm.

Initializing project with template: app


Installing dependencies:
- react
- react-dom
- next
- typescript
- @types/react
- @types/node
- @types/react-dom


added 31 packages, and audited 32 packages in 12s

3 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
Initialized a git repository.

Success! Created my-next-app at

A new version of `create-next-app` is available!
You can update by running: npm i -g create-next-app

╭ Configuring your application for Cloudflare Step 2 of 3
│
├ Created an example API route handler
│
├ Adding the Cloudflare Pages adapter
│ installed @cloudflare/next-on-pages@1, vercel
│
├ Retrieving current workerd compatibility date
│ compatibility date 2023-10-25
│
├ Adding command scripts for development and deployment
│ added commands to `package.json`
│
├ Committing new files
│ git commit
│
╰ Application configured

╭ Deploy with Cloudflare Step 3 of 3
│
├ Do you want to deploy your application?
│ no deploy via `npm run pages:deploy`
│
├  APPLICATION CREATED  Deploy your application with npm run pages:deploy
│
│ Navigate to the new directory cd my-next-app
│ Run the development server npm run pages:dev
│ Deploy your application npm run pages:deploy
│ Read the documentation https://developers.cloudflare.com/pages
│ Stuck? Join us at https://discord.gg/cloudflaredev
│
╰ See you again soon!

2. Route Handlers

はじめに、Hono をインストールします。

npm install hono

app/api/helloapp/api/[...route]にリネームし、その中のroute.tsを以下のように書き換えます。

app/api/[...route]/route.tsx
import { Hono } from "hono";
import { handle } from "hono/vercel";

export const runtime = "edge";

const app = new Hono().basePath("/api");

const route = app.get("/hello", async (c) => {
  return c.jsonT({ name: "John Doe" });
});

export const GET = handle(app);
export type AppType = typeof route;

そしたら、page.tsxからリクエストを送ってみます。

app/page.tsx
"use client";

import { useEffect, useState } from "react";
import { hc } from "hono/client";
import type { AppType } from "./api/[...route]/route";

const client = hc<AppType>("/");

export default function Home() {
  const [name, setName] = useState<string>();

  useEffect(() => {
    client.api.hello
      .$get()
      .then((res) => res.json())
      .then((json) => setName(json.name));
  }, []);

  return <p>{typeof name !== "undefined" ? `Hello ${name}!` : "Loading..."}</p>;
}

Hono が提供する HTTP クライアントhcを用いることで、型を付けることが出来ます。
これは RPC をと呼ばれる機能で、Route Handlers で Hono を使う利点の一つです。
詳しくはドキュメントをご覧ください。

npm run devで起動し、http://localhost:3000にアクセスすると、Hello John Doe!と表示されるはずです。

3. Auth.js

次に、Auth.js をインストールします。

npm install next-auth@beta

app/api/auth/[...nextauth]を作成し、auth.tsroute.tsへそれぞれ以下のように書き込みます。

app/api/auth/[...nextauth]/auth.ts
import NextAuth from "next-auth";
import Line from "next-auth/providers/line";

export const {
  handlers: { GET, POST },
  auth,
} = NextAuth({
  providers: [
    Line({
      clientId: process.env.LINE_CLIENT_ID,
      clientSecret: process.env.LINE_CLIENT_SECRET,
      checks: ["state"],
    }),
  ],
});
app/api/auth/[...nextauth]/route.ts
export { GET, POST } from "./auth";
export const runtime = "edge";

provider はお好きなものを選んでください。
ここでは LINE ログイン を使っています。

.env.localに以下のように環境変数を設定します。
クライアント ID と クライアントシークレットは自分のものに書き替えてください。

.env.local
LINE_CLIENT_ID=YOUR_LINE_CLIENT_ID
LINE_CLIENT_SECRET=YOUR_LINE_CLIENT_SECRET
NEXTAUTH_SECRET=secret
NEXTAUTH_URL=http://localhost:3000

最後にapp/api/[...route]/route.tsを以下のように書き換えます。

app/api/[...route]/route.ts
import { Hono } from "hono";
import { handle } from "hono/vercel";
+ import { auth } from "../auth/[...nextauth]/auth";

export const runtime = "edge";

const app = new Hono().basePath("/api");

const route = app.get("/hello", async (c) => {
+  const session = await auth();
+  const name = session?.user?.name ?? "John Doe";
-  return c.jsonT({ name: "John Doe" });
+  return c.jsonT({ name });
});

export const GET = handle(app);
export type AppType = typeof route;

この段階でブラウザを開いてもまだ認証されていないので、Hello John Doe!と表示されます。
/api/auth/signinからログインしてみましょう。
ログイン後は、John Doeがあなたの名前に置き換わっているはずです。

4. D1 データベース

https://developers.cloudflare.com/d1/

D1 はサーバーレスの SQL データベースです。
これにユーザー情報を保存しておきます。
ORM には Drizzle を使うことにします。

これは Drizzle 公式ページからの引用です。愛されているのがよくわかりますね。

まずは必要なものをインストールします。

npm install @auth/drizzle-adapter drizzle-orm
npm install -D drizzle-kit

以下のコマンドで D1 データベースを作成します。

npx wrangler d1 create <DATABASE_NAME>

プロジェクトルートにwrangler.tomlを作成し、出力をコピペします。

wrangler.toml
[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = <DATABASE_NAME>
database_id = <uuid>

db/schema.tsを作成し、以下のように書き込みます。

db/schema.ts
import {
  integer,
  sqliteTable,
  text,
  primaryKey,
} from "drizzle-orm/sqlite-core";
import type { AdapterAccount } from "@auth/core/adapters";

export const users = sqliteTable("user", {
  id: text("id").notNull().primaryKey(),
  name: text("name"),
  email: text("email").unique(),
  emailVerified: integer("emailVerified", { mode: "timestamp_ms" }),
  image: text("image"),
});

export const accounts = sqliteTable(
  "account",
  {
    userId: text("userId")
      .notNull()
      .references(() => users.id, { onDelete: "cascade" }),
    type: text("type").$type<AdapterAccount["type"]>().notNull(),
    provider: text("provider").notNull(),
    providerAccountId: text("providerAccountId").notNull(),
    refresh_token: text("refresh_token"),
    access_token: text("access_token"),
    expires_at: integer("expires_at"),
    token_type: text("token_type"),
    scope: text("scope"),
    id_token: text("id_token"),
    session_state: text("session_state"),
  },
  (account) => ({
    compoundKey: primaryKey(account.provider, account.providerAccountId),
  })
);

export const sessions = sqliteTable("session", {
  sessionToken: text("sessionToken").notNull().primaryKey(),
  userId: text("userId")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
});

export const verificationTokens = sqliteTable(
  "verificationToken",
  {
    identifier: text("identifier").notNull(),
    token: text("token").notNull(),
    expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
  },
  (vt) => ({
    compoundKey: primaryKey(vt.identifier, vt.token),
  })
);

db/index.tsを作成し、dbをエクスポートします。

db/index.ts
import { drizzle } from "drizzle-orm/d1";

const db = drizzle((process.env as unknown as { DB: D1Database }).DB);

export default db;

tsconfig.jsonからtargetes6に設定します。

tsconfig.json
{
  "compilerOptions": {
-    "target": "es5",
+    "target": "es6",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

以下のコマンドでマイグレーションを実行します。
スキーマのパスなんかはテキトーに読み替えてください。

npx drizzle-kit generate:sqlite --out migrations --schema src/db/schema.ts
npx wrangler d1 migrations apply <DATABASE_NAME> --local

app/api/auth/[...nextauth]/auth.tsを以下のように書き換えます。

app/api/auth/[...nextauth]/auth.ts
import NextAuth from "next-auth";
import Line from "next-auth/providers/line";
+ import { DrizzleAdapter } from "@auth/drizzle-adapter";
+ import db from "@/db";

export const {
  handlers: { GET, POST },
  auth,
} = NextAuth({
+  adapter: DrizzleAdapter(db),
  providers: [
    Line({
      clientId: process.env.LINE_CLIENT_ID,
      clientSecret: process.env.LINE_CLIENT_SECRET,
      checks: ["state"],
    }),
  ],
});

これで認証したユーザー情報が D1 に保存されるようになりました。

wrangler は.envを読んでくれないので、プロジェクトルートに.dev.varsを新たに作成し、以下のように環境変数を設定します。

.dev.vars
LINE_CLIENT_ID=YOUR_LINE_CLIENT_ID
LINE_CLIENT_SECRET=YOUR_LINE_CLIENT_SECRET
NEXTAUTH_SECRET=secret
NEXTAUTH_URL=http://localhost:8788

以下のコマンドで起動し、確認します。

npm run pages:build
npm run pages:dev

ブラウザでログインした後に以下のコマンドで D1 に保存されたユーザー情報を確認します。

npx wrangler d1 execute <DATABASE_NAME> --command="select * from user;" --local

自身のユーザー情報が表示されれば成功です。

確認ができたら、リモートの D1 にもマイグレーションを実行します。

npx wrangler d1 migrations apply <DATABASE_NAME>

5. デプロイ

cloudflare のアカウントが無い方は、予めアカウントを作成しておいてください。

npm run pages:deploy
出力

> my-next-app@0.1.0 pages:deploy
> npm run pages:build && wrangler pages deploy .vercel/output/static


> my-next-app@0.1.0 pages:build
> npx @cloudflare/next-on-pages@1

⚡️ @cloudflare/next-on-pages CLI v.1.7.2
⚡️ Detected Package Manager: npm (10.1.0)
⚡️ Preparing project...
⚡️ Project is ready
⚡️ Building project...
▲  Vercel CLI 32.5.0
▲  WARNING: You should not upload the `.next` directory.
▲  Installing dependencies...
▲  up to date in 851ms
▲
▲  29 packages are looking for funding
▲  run `npm fund` for details
▲  Detected Next.js version: 14.0.0
▲  Detected `package-lock.json` generated by npm 7+
▲  Running "npm run build"> my-next-app@0.1.0 build
▲  > next build
▲  ▲ Next.js 14.0.0
▲  - Environments: .env.local
▲  Creating an optimized production build ...
▲  ✓ Compiled successfully
▲  Linting and checking validity of types ...
▲  Collecting page data ...
▲  ⚠ Using edge runtime on a page currently disables static generation for that page
▲  Generating static pages (0/5) ...
▲  Generating static pages (1/5)
▲  Generating static pages (2/5)
▲  Generating static pages (3/5)
▲  ✓ Generating static pages (5/5)
▲  Finalizing page optimization ...
▲  Collecting build traces ...
▲
▲  Route (app)                              Size     First Load JS
▲  ┌ ○ /                                    1.94 kB        89.4 kB
▲  ├ ○ /_not-found                          882 B          88.3 kB
▲  ├ ℇ /api/[...route]                      0 B                0 B
▲  └ ℇ /api/auth/[...nextauth]              0 B                0 B
▲  + First Load JS shared by all            87.4 kB
▲  ├ chunks/472-19968b8e5aaa98c5.js       32.3 kB
▲  ├ chunks/fd9d1056-4dbbcf07a05c3f48.js  53.2 kB
▲  ├ chunks/main-app-ea6aadca9bc9596d.js  232 B
▲  └ chunks/webpack-1eb62cb6608eefa9.js   1.69 kB
▲  ○  (Static)        prerendered as static HTML
▲  ℇ  (Edge Runtime)  server-rendered on demand using the Edge Runtime
▲  Traced Next.js server files in: 526.057ms
▲  Created all serverless functions in: 977.329ms
▲  Collected static files (public/, static/, .next/static): 5.489ms
▲  Build Completed in .vercel/output [19s]
⚡️ Completed `npx vercel build`.

⚡️ Build Summary (@cloudflare/next-on-pages v1.7.2)
⚡️
⚡️ Edge Function Routes (2)
⚡️   ┌ /api/[...route]
⚡️   └ /api/auth/[...nextauth]
⚡️
⚡️ Prerendered Routes (3)
⚡️   ┌ /
⚡️   ├ /favicon.ico
⚡️   └ /index.rsc
⚡️
⚡️ Other Static Assets (30)
⚡️   ┌ /_app.rsc.json
⚡️   ├ /_document.rsc.json
⚡️   ├ /_error.rsc.json
⚡️   ├ /404.html
⚡️   └ ... 26 more

⚡️ Build log saved to '.vercel/output/static/_worker.js/nop-build-log.json'
⚡️ Generated '.vercel/output/static/_worker.js/index.js'.
⚡️ Build completed in 0.57s
No project selected. Would you like to create one or use an existing project?
❯ Create a new project
  Use an existing project
✔ Enter the name of your new project: … my-next-app
✔ Enter the production branch name: … main
✨ Successfully created the 'my-next-app' project.
▲ [WARNING] Warning: Your working directory is a git repo and has uncommitted changes

  To silence this warning, pass in --commit-dirty=true


🌍  Uploading... (34/34)

✨ Success! Uploaded 34 files (2.55 sec)

✨ Uploading _headers
Attaching additional modules:
- __next-on-pages-dist__/functions/api/[...route].func.js (esm)
- __next-on-pages-dist__/functions/api/auth/[...nextauth].func.js (esm)
- __next-on-pages-dist__/manifest/27d10e462fba646daaa29468a0656e12.js (esm)
- __next-on-pages-dist__/webpack/039c9cdc38e41ee02c5ae5cf5c08df22.js (esm)
✨ Compiled Worker successfully
✨ Uploading Worker bundle
✨ Uploading _routes.json
✨ Deployment complete! Take a peek over at

デプロイできたら、プロジェクトの設定より環境構築と互換性フラグ、D1 データベース バインディングを設定します。
NEXTAUTH_SECRETopenssl rand -hex 32で生成したものを、NEXTAUTH_URLはデプロイされた URL を設定してください。
互換性フラグと D1 データベース バインディンは Functions の中にあります。



設定は再デプロイすると反映されます。

おわり!

できた ❗

Discussion