Next.jsのRoute HandlersでHonoを使いつつAuth.jsで認証してCloudflare PagesにDeployしたい
はじめに
はじめまして、yu7400ki です。
ちょっとした好奇心から、Next.js + Hono + Auth.js を試してみたので、記事として残しておこうと思います。
Next.js
言わずとしれた React のフレームワークです。
今回は App Router を使って書いていきます。
Route Handlers は Pages Router の API Routes と同等の機能を持ちます。
Hono
今最も熱い 🔥 と言っても過言ではない(かもしれない)、イケてる Web フレームワークです。
その特徴はなんといっても、様々なランタイムで動くこと。
ほんとに色々対応しているので、ぜひドキュメントをご確認ください。
今回は Route Handlers の中で Hono を使います。
Auth.js
以前までNextAuth.jsと呼ばれていた認証ライブラリです。
Next.js だけでなく SvelteKit や SolidStart などにも対応するようになりました。
エッジランタイムでも動作します。
ただし、現時点ではベータ版という位置づけです。
Cloudflare Pages
Cloudflare が提供する「フロントエンド開発者のためのフルスタックプラットフォーム」らしいです。
Functions を利用することで、なんかうまいことエッジで動くっぽいです。
ここまでランタイムの話に触れていたのは、最終的にエッジで動かすためです。
環境構築
1. セットアップ
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/hello
をapp/api/[...route]
にリネームし、その中のroute.ts
を以下のように書き換えます。
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
からリクエストを送ってみます。
"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.ts
とroute.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"],
}),
],
});
export { GET, POST } from "./auth";
export const runtime = "edge";
provider はお好きなものを選んでください。
ここでは LINE ログイン を使っています。
.env.local
に以下のように環境変数を設定します。
クライアント ID と クライアントシークレットは自分のものに書き替えてください。
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
を以下のように書き換えます。
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 データベース
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
を作成し、出力をコピペします。
[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = <DATABASE_NAME>
database_id = <uuid>
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
をエクスポートします。
import { drizzle } from "drizzle-orm/d1";
const db = drizzle((process.env as unknown as { DB: D1Database }).DB);
export default db;
tsconfig.json
からtarget
をes6
に設定します。
{
"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
を以下のように書き換えます。
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
を新たに作成し、以下のように環境変数を設定します。
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_SECRET
はopenssl rand -hex 32
で生成したものを、NEXTAUTH_URL
はデプロイされた URL を設定してください。
互換性フラグと D1 データベース バインディンは Functions の中にあります。
設定は再デプロイすると反映されます。
おわり!
できた ❗
Discussion