タイトルの構成でプロダクトは作れるのか
「Next.js + Neon + Drizzle + R2 + Workers」みたいな感じで調べても, 一致する記事がおそらく存在しない.
学習を兼ねてブログサイトを作ってみようと思いこの技術構成にした. しかし,キチンと動くことが確認できない. であれば僕が最初の一人目になることとしよう!
この記事について
最近は「〇〇を作ってみよう」みたいなニュアンスの記事が多いですが, コピペで完成品が出来てしまう関係上, 使い方をなんとなく知れるだけで, 全く理解に繋がらないと感じている. また, 自分のプロダクトを作る際に汎用性が無いと困るので, あくまで"プロダクトでタイトルのサービスを使えるようにする方法"というスタイルで記す.
プラス, 公式の一次情報で調べるという過程も学習の際大切だと感じた. 公式ドキュメントに載っているコード等がそのまま流用出来る場合は, ドキュメントに従ったことは示すがコード等は載せない. 是非置いてあるリンクに飛んで, 公式情報とこのページを行き来しながらテストを行って欲しい.
なお, このページは自分用のメモをそのまま転用しただけなので, Postmanや[[...route]]等が突然登場するが, その使い方や仕様は載せていないので, ご自身で調べることを推奨する.
Next.jsプロジェクトをWorkers用で作成する
Next.js · Cloudflare Workers docs
OpenNextを使用するが, Windowsはサポートされていないため, 実体はWSL上に持たせる必要がある.
(パッケージマネージャー)
【完全比較】npm vs pnpm vs Yarn vs Bun、最強のパッケージマネージャーはどれだ? #Node.js - Qiita
好きなものを選べばOK. 今回はpnpmを使用する.
Wrangler(ラングラー)をインストールする
pnpm add -D wrangler@latest
Cloudflareのアカウントを作成し, 以下を実行後, 表示されるページでAllowを押す.
wrangler login
プロジェクトを作成する.
pnpm create cloudflare@latest test-next-app --framework=next
Do you want to deploy your application?
のみ, Yesを選択.
🔍 View Project
にデプロイされたプロジェクトのURLが表示されています.

クリックしてこうなってればOK.
DBの用意
Neon · Cloudflare Workers docs
Hyperdriveを推奨とあるが, Neon serverless driverを使用する.
【Hyperdrive】Next.js+NeonをWorkersにデプロイするときの注意点 #CloudflareWorkers - Qiita
Neonでプロジェクトを作成
versionはデフォルトでOK.
リージョンは一番近いシンガポールに設定.
Neon Authは, バックエンドを持たないプロジェクトで認証機能を持たせたいときに利用する.
workerからDB操作をする
ドキュメントに従って, テストデータを入れておく.
ConnectからConnection stringを確認しコピーする.
npx wrangler secret put DATABASE_URL
実行後, 入力待ち状態になるので, Connection stringをペーストする.
以下を実行.
pnpm add @neondatabase/serverless
実行結果

クエリの実行方法を変更する.
ローカルで動作確認をするため, .env.localを作成し,
DATABASE_URL=postgresql:さっきペーストしたやつ
を用意しておく.
Next.jsで動かすので, ファイルのパスとファイル名, コードを変更する.
'app'直下にapiフォルダを作成し,中にroute.ts を作成する.
//path: src/app/api/route.ts
import { Client } from "@neondatabase/serverless";
import { NextResponse } from "next/server";
export async function GET() {
const client = new Client(process.env.DATABASE_URL!);
await client.connect();
// elementsテーブルから全ての行を取得
const { rows } = await client.query("SELECT * FROM elements");
await client.end();
// 取得した結果をJSON形式で返す
return NextResponse.json(rows);
}
動作確認
まずはローカルでの動作確認
pnpm preview
ビルド成功後,
curl http://localhost:8787/api
レスポンスに
Content:
[{"id":1,"elementname":"Hydrogen","atomicnumber":1,"symbol":"H"},{"id":2,"elementname":"Helium","atomicnumber":2,"symbol":"He"},{"id":3,"elementname":"Lithium","atomicnumber":3,"symbol":"Li"},
{"id":4,...
が含まれていればOK.
デプロイしても動くか確認.
localhost:8787の部分を, デプロイしたURLに変更し, 同じレスポンスになればOK.
Drizzleを使えるようにする.
Drizzle ORM - Drizzle with Neon Postgres
基本的にこれに従う
pnpm add drizzle-orm
pnpm add -D drizzle-kit
環境変数はもう設定済みなのでスルー
src直下にdbフォルダを作成し, 直下にdb.tsを作成する.
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle({ client: sql });
※もとのコードには
import { config } from "dotenv";
config({ path: ".env" }); // or .env.local
が含まれているが, Next.jsでは自動で環境変数を参照するので必要ない.
dbフォルダ直下にschema.tsを作成する.全く一緒なので省略.(後ほど修正)
プロジェクトのルートパス(srcと同階層)にdrizzle.config.tsを作成.
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
変更を生成する.
npx drizzle-kit generate
すると, migrationsフォルダが生成される.
続いて, マイグレーションを実行する.
npx drizzle-kit migrate
失敗した.
user1@NOTE-ONE:~/workers-next-app$ npx drizzle-kit migrate
No config path provided, using default 'drizzle.config.ts'
Reading config file '/home/user1/workers-next-app/drizzle.config.ts'
Error Please provide required params for Postgres driver:
[x] url: undefined
要は, drizzle-kitはNextで動いているわけじゃないから, 環境変数のパスが分からないと.
結局dotenvは必要だった.
pnpm add dotenv
ファイルを修正
import { config } from 'dotenv';
import { defineConfig } from "drizzle-kit";
config({ path: '.env.local' });
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
実行時怒られたけど無視する
Warning '@neondatabase/serverless' can only connect to remote Neon/Vercel Postgres/Supabase instances through a websocket
確認すると, テーブルは増えているので問題ない.

続いて, Insert Data, Select Data, Update Data, Delete Dataのテストを行います.
db直下にqueriesフォルダを作成する.
中に, insert.ts, select.ts, update.ts, delete.tsを作成する.
getColumnsavailable starting fromdrizzle-orm@1.0.0-beta.2(read more here)
If you are on pre-1 version(like0.45.1) then usegetTableColumns
とのことですが, なぜか公式はベータ版の機能をデフォで使わせようとしてくる.
getColumsをgetTableColumnsに書き換えたselect.tsはこちら
import { asc, between, count, eq, getColumns, sql } from 'drizzle-orm';
import { db } from '../db';
import { SelectUser, usersTable, postsTable } from '../schema';
export async function getUserById(id: SelectUser['id']): Promise<
Array<{
id: number;
name: string;
age: number;
email: string;
}>
> {
return db.select().from(usersTable).where(eq(usersTable.id, id));
}
export async function getUsersWithPostsCount(
page = 1,
pageSize = 5,
): Promise<
Array<{
postsCount: number;
id: number;
name: string;
age: number;
email: string;
}>
> {
return db
.select({
...getColumns(usersTable),
postsCount: count(postsTable.id),
})
.from(usersTable)
.leftJoin(postsTable, eq(usersTable.id, postsTable.userId))
.groupBy(usersTable.id)
.orderBy(asc(usersTable.id))
.limit(pageSize)
.offset((page - 1) * pageSize);
}
export async function getPostsForLast24Hours(
page = 1,
pageSize = 5,
): Promise<
Array<{
id: number;
title: string;
}>
> {
return db
.select({
id: postsTable.id,
title: postsTable.title,
})
.from(postsTable)
.where(between(postsTable.createdAt, sql`now() - interval '1 day'`, sql`now()`))
.orderBy(asc(postsTable.title), asc(postsTable.id))
.limit(pageSize)
.offset((page - 1) * pageSize);
}
これらをAPIで呼び出して実行する.
src/app/apiに直下にpostフォルダを作成し, 中にroute.tsを作成する.
import { InsertPost } from '@/db/schema';
import { NextRequest, NextResponse } from "next/server";
import { createPost } from "@/db/queries/insert";
export async function POST(req: NextRequest) {
try {
const data: InsertPost = await req.json();
await createPost(data);
return NextResponse.json({ message: "Post created successfully" }, { status: 201 });
} catch (error) {
return NextResponse.json({ error: "Failed to create post" }, { status: 500 });
}
}
src/app/apiに直下にuserフォルダを作成し, 中にroute.tsを作成する.
import { InsertUser } from '@/db/schema';
import { NextRequest, NextResponse } from "next/server";
import { createUser } from "@/db/queries/insert";
export async function POST(req: NextRequest) {
try {
const data: InsertUser = await req.json();
await createUser(data);
return NextResponse.json({ message: "User created successfully" }, { status: 201 });
} catch (error) {
return NextResponse.json({ error: "Failed to create user" }, { status: 500 });
}
}
postフォルダ内に[id]フォルダを作成し, 中にroute.tsを作成する.
import { NextRequest, NextResponse } from "next/server";
import { updatePost } from "@/db/queries/update";
import type { SelectPost } from "@/db/schema";
export async function PUT(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const body: Partial<Omit<SelectPost, 'id'>> = await req.json();
await updatePost(Number(id), body);
return NextResponse.json(
{ message: 'Post updated successfully' },
{ status: 200 }
);
} catch (error) {
console.error(error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
userフォルダ内に[id]フォルダを作成し, 中にroute.tsを作成する.
import { NextRequest, NextResponse } from "next/server";
import { getUserById } from "@/db/queries/select";
import { deleteUser } from "@/db/queries/delete";
export async function GET(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const user = await getUserById(Number(id));
return NextResponse.json(user);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch user" },
{ status: 500 }
);
}
}
export async function DELETE(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
await deleteUser(Number(id));
return NextResponse.json(
{ message: 'User deleted successfully' },
{ status: 200 }
);
} catch (error) {
console.error(error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
pnpm peviewを実行し, postmanでAPIテストを行う.
createUserの実行
http://localhost:8787/api/userにこのJSONでPOST
{
"name": "first user",
"age": 20,
"email": "first.user@example.com"
}
createPostの実行
http://localhost:8787/api/postにこのJSONでPOST
{
"title": "First Post",
"content": "This is the first post.",
"user_id": 1
}
失敗する.
schema.tsのupdatedAtにデフォルト値が入っていないからだと思われる.
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
.defaultNow()を追加して,
npx drizzle-kit generate
npx drizzle-kit migrate
その後同じPOSTを試みる.
失敗した. 原因を考える.
-
schema.tsでは、アプリ側のプロパティ名はuserId, DB側の実カラム名はuser_idと定義している. - そのため InsertPost が期待する入力キーは userId.
よって, 送信するべきJSONはこうだった.
{
"title": "First Post",
"content": "This is the first post.",
"userId": 1
}
ちゃんと成功した.

Neonから追加されたレコードを確認し, 指定したidのレコードを取得する.

(こちらは, 失敗しまくったのでidが9になっている.)

getUserByIdの実行
postmanで,http://localhost:8787/api/user/1にGETリクエストを送信する.

updatePostの実行
postmanで,http://localhost:8787/api/post/9にGETリクエストを送信する.
今回は, contentに変更を加える.
{
"content": "First post is changed."
}
deleteUserの実行
postmanで,http://localhost:8787/api/user/1にDELETEリクエストを送信する.

これで, CRUDの確認が出来た.
APIをHonoから呼び出せるようにする.
Honoのインストール
pnpm add hono
apiフォルダ直下に[[...route]]フォルダを作成し, 中にroute.tsを作成する.
import { InsertUser } from '@/db/schema';
import { InsertPost } from '@/db/schema';
import { createUser } from "@/db/queries/insert";
import { getUserById } from "@/db/queries/select";
import { deleteUser } from "@/db/queries/delete";
import { updatePost } from "@/db/queries/update";
import type { SelectPost } from "@/db/schema";
import { createPost } from "@/db/queries/insert";
import { Hono } from "hono";
import { handle } from "hono/vercel";
const app = new Hono().basePath("/api");
app.post("/user", async (c) => {
try {
const data: InsertUser = await c.req.json();
await createUser(data);
return c.json({ message: "User created successfully" }, { status: 201 });
} catch (error) {
return c.json({ error: "Failed to create user" }, { status: 500 });
}
});
app.get("/user/:id", async (c) => {
try {
const { id } = c.req.param();
const user = await getUserById(Number(id));
return c.json(user);
} catch (error) {
return c.json({ error: "Failed to fetch user" }, { status: 500 });
}
});
app.delete("/user/:id", async (c) => {
try {
const { id } = c.req.param();
await deleteUser(Number(id));
return c.json({ message: 'User deleted successfully' }, { status: 200 });
} catch (error) {
console.error(error);
return c.json({ error: 'Internal Server Error' }, { status: 500 });
}
});
app.post("/post", async (c) => {
try {
const data: InsertPost = await c.req.json();
await createPost(data);
return c.json({ message: "Post created successfully" }, { status: 201 });
} catch (error) {
return c.json({ error: "Failed to create post" }, { status: 500 });
}
});
app.put("/post/:id", async (c) => {
try {
const { id } = c.req.param();
const body: Partial<Omit<SelectPost, 'id'>> = await c.req.json();
await updatePost(Number(id), body);
return c.json({ message: 'Post updated successfully' }, { status: 200 });
} catch (error) {
console.error(error);
return c.json({ error: 'Internal Server Error' }, { status: 500 });
}
});
export const GET = handle(app)
export const POST = handle(app)
export const PUT = handle(app)
export const DELETE = handle(app)
変更点
- APIの呼び出し方法
- JSONの扱い方法が,
NextRequest,NextResponseから, contextを直接いじるようになった
APIテスト
先ほどと同じく,
pnpm preview
後に, postmanでテストを行う.
user_idやpostのidが増えていることに注意.
確認できたら,
pnpm run deploy
を実行し, デプロイ先でもpostmanでテストを行う.
この時, "Set as variable"の機能を用いると便利.
デプロイ先でも正しく動作することが確認できた.
なお, deleteUserによって消去されたユーザーによって投稿されたポストは自動的に削除されるようになっている.
R2で画像を保存する
OpenNextからR2を使用する場合の1次情報が全然見つけられなかった.
動作が確認できた方法を記す.
R2バケットの作成
R2バケットを作成し, wrangler.jsoncに以下を追加する.
bucket_nameは, R2に作成したバケットの名前.
"r2_buckets": [
{
"binding": "MY_BUCKET",
"bucket_name": "wrangler-test"
}
]
作成後, 型の再生成を行う.
npx wrangler types
images_tableの追加
画像の参照先を保存しておくためのテーブルを新規に作成する.
schema.tsに以下を追加する.
export const imagesTable = pgTable('images_table', {
id: serial('id').primaryKey(),
url: text('url').notNull(),
});
export type InsertImage = typeof imagesTable.$inferInsert;
以下を実行する.
npx drizzle-kit generate
npx drizzle-kit migrate
画像パスを保存する関数の作成
insert.tsに以下の追加, 変更を行う.
import { InsertPost, InsertUser, InsertImage, postsTable, usersTable, imagesTable } from '../schema';
export async function createImage(data: InsertImage) {
await db.insert(imagesTable).values(data);
}
route.tsへのAPIの追加
画像をPOSTしたら, R2に画像を保存し, 保存先の画像を取得するためのAPIパスを生成.
パスをDBに保存する.
生成したパスでGETリクエストをすると, 画像が返される.
といった流れ.
公式ドキュメントなら, c.env...みたいな形でR2を操作できるとあるのですが, OpenNextでNext.jsを動かしている関係上, そうはいかないらしい. 型がunknownになって, エラーを吐く.
自動生成されているcloudflare-env.d.tsに, 以下を追加することで, 型定義をしてあげる.
declare namespace Cloudflare {
interface Env {
MY_BUCKET: R2Bucket; // 追加
IMAGES: ImagesBinding;
ASSETS: Fetcher;
NEXTJS_ENV: string;
WORKER_SELF_REFERENCE: Fetcher /* workers-next-app */;
}
}
なお, 保存名やパスは動作確認用に簡単にしているので, 実運用でも同じようにするべきではない.
また, 最大サイズ等も本来は設定すべき.
import { getCloudflareContext } from "@opennextjs/cloudflare";
が必要になる理由はAIとのチャット画像を参照.
import { InsertImage } from "@/db/schema";
import { createImage } from "@/db/queries/insert";
import { getCloudflareContext } from "@opennextjs/cloudflare";
type Bindings = {
MY_BUCKET: R2Bucket;
};
const app = new Hono<{ Bindings: Bindings }>().basePath("/api");
const { env } = await getCloudflareContext({ async: true });
app.post("/upload", async (c) => {
const body = await c.req.parseBody();
const file = body.file as File;
if (!file) {
return c.json({ error: "file is required" }, 400);
}
const key = `${Date.now()}-${file.name}`;
await env.MY_BUCKET.put(
key,
await file.arrayBuffer(),
{
httpMetadata: {
contentType: file.type,
},
}
);
const imagePath = `/api/images/${key}`;
const url = `${c.req.url.split("/api")[0]}${imagePath}`
try {
const data: InsertImage = { url: url };
await createImage(data);
return c.json({ message: "Image saved successfully" }, { status: 201 });
} catch (error) {
return c.json({ error: "Failed to save image" }, { status: 500 });
}
});
app.get("/images/:key", async (c) => {
try {
const key = c.req.param("key");
const image = await env.MY_BUCKET.get(key);
if (!image) {
return c.json({ error: "Image not found" }, 404);
}
return c.body(image.body, 200, {
"Content-Type": image.httpMetadata?.contentType ?? "image/png",
});
} catch (error) {
return c.json({ error: "Failed to fetch image" }, { status: 500 });
}
});
テスト
postmanでテストを行う.
http://localhost:8788/api/uploadに対して, 以下のようにPOSTリクエストを送信する.

Neonでimages_tableを参照し, 保存されたパスを確認する.

パスをそのままペーストし, GETリクエストを送信すると, 画像が表示されるため, R2に保存した画像が返却されていることを確認できた.(紛らわしい画像で申し訳ない...)

pnpm run deploy
をして, 同様にテストを行っても, 問題無く動作した.
R2のダッシュボードで確認すると, 画像が保存されていることが分かる.

最後に
これで, タイトルの技術構成でプロダクトを作ることが可能であることが分かった.
僕はこれからこの構成でブログサイトを作る. できたらまた記事を作ります. そのときは「Next.js + Neon + Drizzle + R2 でブログサイトを作ろう」的なタイトルだと思います.
間違った情報を載せていた場合は, コメントしてください.




