Cloudflare Pages + Next.js + Hono + D1 + Drizzleで始めるフルスタック構成
はじめに
個人開発でWebサービスを作るにあたり、CloudflareやHonoなど普段の業務では縁のない技術を試してみた。
CRUD操作ができるところまでをまとめてみる。
Cloudflare Pagesとは
Cloudflare社が提供するJAMstackプラットフォーム。
Freeプランで月500回までビルド可能、bandwidthが無制限と無料枠が充実しているのが特徴。
(Next.jsのホスティングといえばVercelだけど商用利用で$20/月は個人だと痛手。。)
Honoとは
軽量かつ高速なWebフレームワーク。
Cloudflare Pages/Workersと親和性が高いExpress的なイメージ。
技術構成
- CLI: wrangler2
- Hosting: Cloudflare Pages + Functions(サーバー処理)
- FW: Next.js(App Router), Hono
- 言語: TypeScript
- DB: Cloudflare D1
- ORM: Drizzle
事前準備
- Cloudflareのサイトにてアカウントを作成
- プロジェクト作成などするために、CLIツール
wragler
をインストール&ログインする
$ npm install -g wrangler
$ wrangler --version
⛅️ wrangler 3.18.0
$ wrangler login
Next.jsのセットアップ
C3(create-cloudflare CLI)を使うことで新規プロジェクトの作成からデプロイまで一気通貫で行うことができてしまう。
$ npm create cloudflare@latest my-next-app -- --framework=next
│
╰ Do you want to deploy your application?
Yes / No
デプロイするを選択するとわずか1分ほどでホスティング完了!
ダッシュボードでもプロジェクトが作成されていることが確認できる。
Honoのセットアップ
今回はApp Routerにて簡単なAPIを作成してみる。
$ npm install hono
import { Hono } from 'hono'
import { handle } from 'hono/vercel'
export const runtime = 'edge';
const app = new Hono().basePath('/api')
app.get('/hello', (c) => {
return c.json({
message: 'Hello Next.js!',
})
})
export const GET = handle(app)
ローカルでAPIを叩いてmessageが返ってきたらセットアップ完了!
$ npm run pages:build
$ npm run pages:dev
D1のセットアップ
DB作成
wrangler
コマンドでDBを作成する。
$ npx wrangler d1 create my-next-app-db
✅ Successfully created DB 'my-next-app-db' in region APAC
Created your database using D1's new storage backend. The new storage backend is not yet recommended for production
workloads, but backs up your data via point-in-time restore.
[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "my-next-app-db"
database_id = "database-id-xxxxxxxxx"
[[d1_databases]]以下はローカルからD1への接続に必要な情報。
ルートディレクトリに wrangler.toml
を作成して貼り付けておく。
なおこの時点でダッシュボードからDBが作成されていることが確認できる。
SQL実行
試しに公式にあるschemaファイルをルートディレクトリにおいて実行してみる。
DROP TABLE IF EXISTS Customers;
CREATE TABLE IF NOT EXISTS Customers (CustomerId INTEGER PRIMARY KEY, CompanyName TEXT, ContactName TEXT);
INSERT INTO Customers (CustomerID, CompanyName, ContactName) VALUES (1, 'Alfreds Futterkiste', 'Maria Anders'), (4, 'Around the Horn', 'Thomas Hardy'), (11, 'Bs Beverages', 'Victoria Ashworth'), (13, 'Bs Beverages', 'Random Name');
ローカルで実行。
なお、--local
を外すと本番で実行される。
$ npx wrangler d1 execute my-next-app-db --local --file=./schema.sql
データが格納されていることを確認する。
$ npx wrangler d1 execute my-next-app-db --local --command='SELECT * FROM Customers'
┌────────────┬─────────────────────┬───────────────────┐
│ CustomerId │ CompanyName │ ContactName │
├────────────┼─────────────────────┼───────────────────┤
│ 1 │ Alfreds Futterkiste │ Maria Anders │
├────────────┼─────────────────────┼───────────────────┤
│ 4 │ Around the Horn │ Thomas Hardy │
├────────────┼─────────────────────┼───────────────────┤
│ 11 │ Bs Beverages │ Victoria Ashworth │
├────────────┼─────────────────────┼───────────────────┤
│ 13 │ Bs Beverages │ Random Name │
└────────────┴─────────────────────┴───────────────────┘
本番にも反映
$ npx wrangler d1 execute my-next-app-db --file=./schema.sql
$ npx wrangler d1 execute my-next-app-db --command='SELECT * FROM Customers'
Binding D1
デプロイしたNext.js(Pages Function)とD1を紐づける。
Cloudflare Pagesの場合、ローカルではwrangler.toml
から紐付けされるが本番では読み込まれないため、ダッシュボードから設定する必要がある。
該当のプロジェクトから、設定、Functionsを開く。
D1データベース バインディングの編集にて、該当のD1を選択する。
これで本番のPages Functionとの紐付けができたのでD1のセットアップ完了!
APIからD1を呼び出す
route.ts
に処理を書いていく。
Honoのドキュメントを参考に以下のように書いてみる
import { D1Database } from '@cloudflare/workers-types';
import { Hono } from 'hono'
import { handle } from 'hono/vercel'
export const runtime = 'edge';
// This ensures c.env.DB is correctly typed
type Bindings = {
DB: D1Database
}
const app = new Hono<{ Bindings: Bindings}>().basePath('/api')
// Accessing D1 is via the c.env.YOUR_BINDING property
app.get("/query/customers", async (c) => {
let { results } = await c.env.DB.prepare("SELECT * FROM customers").all()
return c.json(results)
})
export const GET = handle(app)
ローカルの場合は起動コマンドに--d1=BINDING_NAME
のフラグが必要みたいなので--d1=DB
を追加して起動してみる。
/api/query/customers
を叩いてみるが500エラー
$ ✘ [ERROR] TypeError: Cannot read properties of undefined (reading 'prepare')
prepareに問題がありそう?
いろいろドキュメントを漁ると、next-on-pagesからはprocess経由でBindingsにアクセスができるらしい。
env周りを以下のように修正
- await c.env.DB.prepare("SELECT * FROM customers").all()
+ await process.env.DB.prepare("SELECT * FROM customers").all()
tsエラーがでるのでこちらも追加しておく
declare global {
namespace NodeJS {
interface ProcessEnv {
DB: D1Database;
}
}
}
再度実行。
が、またもエラー...
$ [wrangler:inf] GET /api/query/customers 500 Internal Server Error (28ms)
✘ [ERROR] Error: D1_ERROR: no such table: customers
customersテーブルがない?
先ほどテーブルの作成は確認できているので、ローカルからD1への接続がうまくいっていないぽい。
こちらも調査したところ公式ドキュメントに答えがあった。
# If you are only using Pages + D1, you only need the below in your wrangler.toml to interact with D1 locally.
[[d1_databases]]
binding = "DB" # Should match preview_database_id
database_name = "YOUR_DATABASE_NAME"
database_id = "the-id-of-your-D1-database-goes-here" # wrangler d1 info YOUR_DATABASE_NAME
preview_database_id = "DB" # Required for Pages local development <- 追記
ローカルでD1に接続する場合は、prevew_database_id
を追記する必要があるらしい。
上記のとおり修正し、再度実行。
ようやく成功!
Drizzle導入
Drizzleとは
D1をサポートしているエッジ対応のORM。
他にもkyseryなどいくつか候補があったが、一番評判が良さそうなDrizzleを使ってみる。
Drizzleセットアップ
というわけでインストール。
$ npm install drizzle-orm
$ npm install -D drizzle-kit
続いてschemaの定義を作成
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
userId: integer("userId", { mode: "number" })
.primaryKey({ autoIncrement: true })
.notNull(),
userName: text("userName").notNull(),
});
drizzleの設定ファイルも追加
import type { Config } from "drizzle-kit";
export default {
schema: "./schema.ts",
out: "./drizzle/migrations",
driver: "d1",
dbCredentials: {
wranglerConfigPath: "wrangler.toml",
dbName: "my-next-app-db",
},
} satisfies Config;
generateを実行すると、es5がサポート外というエラーになるのでtsconfigを修正する
$ npx drizzle-kit generate:sqlite
drizzle-kit: v0.20.6
drizzle-orm: v0.29.1
No config path provided, using default 'drizzle.config.ts'
ERROR: Transforming const to the configured target environment ("es5") is not supported yet
- "target": "es5",
+ "target": "es6",
成功するとmigrationファイルが作成される(ファイル名は自動で命名)
$ [✓] Your SQL migration file ➜ drizzle/migrations/0000_far_smasher.sql 🚀
wrangler.toml
にmigrations_dirを追加
+ migrations_dir = "drizzle/migrations"
migration実行!
$ npx wrangler d1 migrations apply my-next-app-db --local
Migrations to be applied:
┌──────────────────────┐
│ name │
├──────────────────────┤
│ 0000_far_smasher.sql │
└──────────────────────┘
✔ About to apply 1 migration(s)
Your database may not be available to serve requests during the migration, continue? … yes
🌀 Mapping SQL input into an array of statements
🌀 Executing on local database my-next-app-db (DB) from .wrangler/state/v3/d1:
┌──────────────────────┬────────┐
│ name │ status │
├──────────────────────┼────────┤
│ 0000_far_smasher.sql │ ✅ │
└──────────────────────┴────────┘
CRUD処理
Drizzleで処理を書いてローカルD1に繋いで動かしてみる。
SQLライクな書き方なので馴染みやすい。
/**
* get users
*/
app.get("/users", async (c) => {
const db = drizzle(process.env.DB);
const result = await db.select().from(users).all();
return c.json(result);
});
/**
* create users
*/
app.post("/users", async (c) => {
const params = await c.req.json<typeof users.$inferSelect>();
const db = drizzle(process.env.DB);
const result = await db
.insert(users)
.values({
userName: params.userName,
})
.execute();
return c.json(result);
});
export const GET = handle(app);
export const POST = handle(app);
作成したPOSTとGETを実行してみる
まとめ
後半雑になってしまったが、さくっとCloudflareでフルスタック構成を試せた!
次はSSRなどパフォーマンスの観点からも調査していきたい。
Discussion