1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js + Neon + Drizzle + R2 を使ったプロダクトをCloudflare Workersで公開する.

1
Last updated at Posted at 2026-02-24

タイトルの構成でプロダクトは作れるのか

「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が表示されています.
image-7.png
クリックしてこうなってればOK.

DBの用意

Neon · Cloudflare Workers docs
Hyperdriveを推奨とあるが, Neon serverless driverを使用する.
【Hyperdrive】Next.js+NeonをWorkersにデプロイするときの注意点 #CloudflareWorkers - Qiita

Neonでプロジェクトを作成

image-8.png

versionはデフォルトでOK.
リージョンは一番近いシンガポールに設定.
Neon Authは, バックエンドを持たないプロジェクトで認証機能を持たせたいときに利用する.

workerからDB操作をする

ドキュメントに従って, テストデータを入れておく.
ConnectからConnection stringを確認しコピーする.

npx wrangler secret put DATABASE_URL

実行後, 入力待ち状態になるので, Connection stringをペーストする.
以下を実行.

pnpm add @neondatabase/serverless

実行結果
image-9.png
クエリの実行方法を変更する.
ローカルで動作確認をするため, .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

確認すると, テーブルは増えているので問題ない.
image-10.png
続いて, Insert Data, Select Data, Update Data, Delete Dataのテストを行います.
db直下にqueriesフォルダを作成する.
中に, insert.ts, select.ts, update.ts, delete.tsを作成する.

getColumns available starting from drizzle-orm@1.0.0-beta.2(read more here)
If you are on pre-1 version(like 0.45.1) then use getTableColumns

とのことですが, なぜか公式はベータ版の機能をデフォで使わせようとしてくる.
getColumsgetTableColumnsに書き換えた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"
}

image-11.png

createPostの実行

http://localhost:8787/api/postにこのJSONでPOST

{
    "title": "First Post",
    "content": "This is the first post.",
    "user_id": 1
}

失敗する.
schema.tsupdatedAtにデフォルト値が入っていないからだと思われる.

  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
}

ちゃんと成功した.
image-12.png
Neonから追加されたレコードを確認し, 指定したidのレコードを取得する.
image-14.png
(こちらは, 失敗しまくったのでidが9になっている.)
image-13.png

getUserByIdの実行

postmanで,http://localhost:8787/api/user/1にGETリクエストを送信する.
image-15.png

updatePostの実行

postmanで,http://localhost:8787/api/post/9にGETリクエストを送信する.
今回は, contentに変更を加える.

{
    "content": "First post is changed."
}

image-16.png

deleteUserの実行

postmanで,http://localhost:8787/api/user/1にDELETEリクエストを送信する.
image-17.png
これで, 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 });
  }
});

image-19.png
image-20.png

テスト

postmanでテストを行う.
http://localhost:8788/api/uploadに対して, 以下のようにPOSTリクエストを送信する.
image-21.png
Neonでimages_tableを参照し, 保存されたパスを確認する.
image-22.png
パスをそのままペーストし, GETリクエストを送信すると, 画像が表示されるため, R2に保存した画像が返却されていることを確認できた.(紛らわしい画像で申し訳ない...)
image-23.png

pnpm run deploy

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

最後に

これで, タイトルの技術構成でプロダクトを作ることが可能であることが分かった.
僕はこれからこの構成でブログサイトを作る. できたらまた記事を作ります. そのときは「Next.js + Neon + Drizzle + R2 でブログサイトを作ろう」的なタイトルだと思います.

間違った情報を載せていた場合は, コメントしてください.

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?