はじめに
こんにちは、Watanabe Jin(@Sicut_study)です。
AIが日々進化しており、個人開発を誰もが気軽にできるような世界になってきました。
私もかなり焦りながら勉強をしておりついていくのに必死です。
個人開発のハードルが下がった中で、日々多くの個人開発がリリースされています。
その中でNext.js × Hono × Cloudflareという構成をネット上で見る機会が増えました。
この構成をすることは個人開発者にとっては大きなメリットがあります。
- (だいたい)無料枠でサービスが動かせる
- 簡単にデプロイすることができる
- Honoと相性が良いので軽量で高速なAPIが使える
ユーザー体験をより向上させながら、ほぼコスト無しで個人開発ができるのは大変魅力的です。Cloudflareは個人開発をしていくなら絶対に抑えておきたいスキルとなっています。
しかし、このチュートリアルを作りながらCloudflareについてまとまって解説がされているものが少ないことに気づきました。2025年4月現在の時点ではある程度開発になれている人が扱えるようなサービスといった印象を持っています。
そこで本チュートリアルでは初心者の人でもCloudflareを使えるように個人開発で利用するであろう主要なサービス「Cloudflare Pages」「Cloudflare Workers」「Cloudflare D1」「Cloudflare R2」をすべて利用してファイル共有サービスを開発します。
網羅的に学べる教材は世界でも少ないのでぜひともCloudflareを学ぶために本チュートリアルを行っていただけたら嬉しいです。
動画教材も用意しています
こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材でわからない細かい箇所があれば動画も活用ください
対象者
- Next.jsを学んでみたい人
- Cloudflareを体系的に学びたい人
- 個人開発をしたい人
- アプリを作りながら学びたい人
Reactの基本的な仕組みを理解している人であれば2時間程度で学習することが可能です
What is Cloudflare?
Cloudflareは、ウェブサイトやアプリケーションのパフォーマンスを向上させ、セキュリティを強化するためのサービスです。最近では単なるセキュリティやCDNサービスを超えて、サーバーレスコンピューティングやホスティングなどの領域にも拡大しています。
今回は多くのサービスの中でもNext.jsの開発をする上で抑えておきたい
- Cloudflare Pages
- Cloudflare Workers
- Cloudflare D1
- Cloudflare R2
すべて活用してファイル共有アプリケーションを作成していきます。
なぜCloudflareが人気なのか?
Next.jsをデプロイするとなると様々なホスト先があります。
例えば「Vercel」「Firebase」「AWS」などはその候補になるかと思います。
しかし、最近は個人開発者を中心にCloudflareを使う人が増えている傾向にあります。
理由は大きく3つあります。
1. 高速なデプロイ/実行環境
Next.jsのデプロイにはクライアントサイドにCloudflare Pages、サーバーサイドにCloudflare Workersを使用することになります。Cloudflareを利用することによってアプリケーションをユーザーにより近い場所(エッジ)にデータ処理能力やリソースを配置するエッジネットワークの上にデプロイが可能です。
こうすることで世界中のどの地域からも高速にアプリケーションを使うことができるようになります。
2. 無料枠の寛大さ
個人開発をやる上で重要になってくる要素に「サーバーの費用」があります。
最初はユーザーもいない状況なのでなるべくお金をかけずにホスティングがしたいと考えます。
たとえばVercelを使うとなるとマネタイズが見込まれるサイトの場合は有料プランを利用しないといけません。Firebaseの場合、従量課金のため利用されるとその分お金がかかってきてしまいます。
Cloudflareは無料利用枠がかなりあり、小規模から中規模のプロジェクトであればほぼ無料で運用が可能です。コスト面で大きなメリットがあるのが個人開発者から注目を浴びている大きな理由です。
3. Honoと相性抜群
Cloudflare WorkersはHonoをネイティブにサポートしており、Honoの性能を最大限発揮するように作られています。HonoをAPIに利用することで超軽量に高速に動作することが可能です。
今回使用するCloudflareサービス
Next.jsでアプリケーションを開発する中で、利用頻度が高いものをまとめてハンズオンの中で学べるように設計しています。
これ1本やるだけでCloudflareの必須項目を理解することができます。
またCloudflare Workersと同等のサーバーレス環境で開発する「Wrangler」も利用します。
WranglerはCloudflare Workersを開発するためのCLIツールです。利用することでデプロイなどが簡単に行えるだけでなく、ローカルでデータベースを用意するのを簡単に行えます。
Cloudflareの開発には必須のツールとなっていますのでしっかりと学んでいきましょう。
1. 環境構築
まずはNext.jsとTailwindCSSの環境構築を行います。
Node.jsの環境があるかをチェックしましょう。
$ node -v
v22.0.4
ここでNode.jsが入っていない人はこちらのサイトからインストールしてください。
難しいと感じる方はQiitaの記事がたくさんありますので参考にしてみてください。
Next.jsの環境を作るのですが、Cloudflareのテンプレートを利用すると「Next.jsの環境構築」「Cloudflareの初期設定」「最初のデプロイ」まですべて行ってくれるので今回は利用します。
その前にCloudflareのアカウントを用意してください。
アカウント作成ができたら、コマンドラインでCloudflareを操作できるツール「Wrangler」もいれておきます。
$ npm install -g wrangler
-g
コマンドでインストールすることですべてのプロジェクトでwrangler
コマンドを利用できます。
次にアカウント認証をして、デプロイのときにデプロイするアカウントがわかるようにしてあげます。
$ wrangler login
すでにログインしていればこのような画面がでるので「Allow」をクリッして完了です。
ここまでいったらテンプレートを利用してNext.js環境を作りましょう。
$ npm create cloudflare@latest -- file-share-app --framework=next
Need to install the following packages:
create-cloudflare@2.43.3
Ok to proceed? (y) y
> npx
> create-cloudflare file-share-app --framework=next
────────────────────────────────────────────────────────────
👋 Welcome to create-cloudflare v2.43.3!
🧡 Let's get started.
────────────────────────────────────────────────────────────
╭ Create an application with Cloudflare Step 1 of 3
│
├ In which directory do you want to create your application?
│ dir ./file-share-app
│
├ What would you like to start with?
│ category Framework Starter
│
├ Which development framework do you want to use?
│ framework Next.js
│
├ Select your deployment platform
│ platform Workers with Assets (BETA)
│
├ Continue with Next.js (using Node.js compat + Workers Assets) via `npx create-next-app@~15.2.4 file-share-app`
│
Need to install the following packages:
create-next-app@15.2.4
Ok to proceed? (y) 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 your code inside a `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to use Turbopack for `next dev`? … No / Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No / Yes
Creating a new Next.js app in /home/jinwatanabe/workspace/qiit/file-share-app.
Using npm.
Initializing project with template: app-tw
Installing dependencies:
- react
- react-dom
- next
Installing devDependencies:
- typescript
- @types/node
- @types/react
- @types/react-dom
- @tailwindcss/postcss
- tailwindcss
- eslint
- eslint-config-next
- @eslint/eslintrc
added 317 packages, and audited 318 packages in 41s
131 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
Initialized a git repository.
Success! Created file-share-app at /home/jinwatanabe/workspace/qiit/file-share-app
├ Copying template files
│ files copied to project directory
│
╰ Application created
╭ Configuring your application for Cloudflare Step 2 of 3
│
├ Installing wrangler A command line tool for building Cloudflare Workers
│ installed via `npm install wrangler --save-dev`
│
├ Installing @cloudflare/workers-types
│ installed via npm
│
├ Adding latest types to `tsconfig.json`
│ added @cloudflare/workers-types/2023-07-01
│
├ Adding the Cloudflare adapter
│ installed @opennextjs/cloudflare@~1.0.0-beta.0 || ^1.0.0, @cloudflare/workers-
types
│
├ Updating `next.config.ts`
│ updated `next.config.ts`
│
├ Adding Wrangler files to the .gitignore file
│ updated .gitignore file
│
├ Updating `package.json` scripts
│ updated `package.json`
│
├ Do you want to use git for version control?
│ yes git
│
├ Initializing git repo
│ initialized git
│
├ Committing new files
│ git commit
│
╰ Application configured
╭ Deploy with Cloudflare Step 3 of 3
│
├ Do you want to deploy your application?
│ yes deploy via `npm run deploy`
│
├ Logging into Cloudflare checking authentication status
│ logged in
│
├ Selecting Cloudflare account retrieving accounts
│ account Jin.watanabe.6g@gmail.com's Account
│
> file-share-app@0.1.0 deploy
> opennextjs-cloudflare build && opennextjs-cloudflare deploy
┌─────────────────────────────┐
│ OpenNext — Cloudflare build │
└─────────────────────────────┘
App directory: /home/jinwatanabe/workspace/qiit/file-share-app
Next.js version : 15.2.4
@opennextjs/cloudflare version: 1.0.0-beta.0
@opennextjs/aws version: 3.5.4
┌─────────────────────────────────┐
│ OpenNext — Building Next.js app │
└─────────────────────────────────┘
> file-share-app@0.1.0 build
> next build
Using vars defined in .dev.vars
Using vars defined in .dev.vars
▲ Next.js 15.2.4
Creating an optimized production build ...
Using vars defined in .dev.vars
Using vars defined in .dev.vars
Using vars defined in .dev.vars
✓ Compiled successfully
✓ Linting and checking validity of types
✓ Collecting page data
✓ Generating static pages (5/5)
✓ Collecting build traces
✓ Finalizing page optimization
Route (app) Size First Load JS
┌ ○ / 5.57 kB 106 kB
└ ○ /_not-found 977 B 101 kB
+ First Load JS shared by all 100 kB
├ chunks/4bd1b696-5b6c0ccbd3c0c9ab.js 53.2 kB
├ chunks/684-c131fa2291503b5d.js 45.3 kB
└ other shared chunks (total) 1.88 kB
○ (Static) prerendered as static content
┌──────────────────────────────┐
│ OpenNext — Generating bundle │
└──────────────────────────────┘
Bundling middleware function...
Bundling static assets...
Bundling cache assets...
Building server function: default...
Applying code patches: 7.030s
# copyPackageTemplateFiles
⚙️ Bundling the OpenNext server...
Applying code patches:
- patching require
- patching cacheHandler
- patching 'require(this.middlewareManifestPath)'
- patching `require.resolve` call
All 4 patches applied
Worker saved in `/home/jinwatanabe/workspace/qiit/file-share-app/.open-next/worker.js` 🚀
OpenNext build complete.
┌──────────────────────────────┐
│ OpenNext — Cloudflare deploy │
└──────────────────────────────┘
Populating R2 incremental cache...
Successfully populated cache with 4 assets
Tag cache does not need populating
Cloudflare collects anonymous telemetry about your usage of Wrangler. Learn more at https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/telemetry.md
⛅️ wrangler 4.7.2
------------------
🌀 Building list of assets...
🌀 Starting asset upload...
🌀 Found 31 new or modified static assets to upload. Proceeding with upload...
+ /BUILD_ID
+ /_next/static/chunks/app/page-4435a7d972eaed54.js
+ /window.svg
+ /_next/static/BwDcaYnzzAsFa5A-aKJDS/_buildManifest.js
+ /next.svg
+ /_next/static/chunks/webpack-05cc406d6a87b1a9.js
+ /cdn-cgi/_next_cache/BwDcaYnzzAsFa5A-aKJDS/_not-found.cache
+ /cdn-cgi/_next_cache/BwDcaYnzzAsFa5A-aKJDS/index.cache
+ /_next/static/media/93f479601ee12b01-s.p.woff2
+ /_next/static/chunks/main-dac8f658a250f732.js
+ /_next/static/chunks/framework-f593a28cde54158e.js
+ /_next/static/BwDcaYnzzAsFa5A-aKJDS/_ssgManifest.js
+ /_next/static/chunks/pages/_error-cc3f077a18ea1793.js
+ /file.svg
+ /_next/static/chunks/app/layout-07b9c3193648fc59.js
+ /cdn-cgi/_next_cache/BwDcaYnzzAsFa5A-aKJDS/500.cache
+ /_next/static/css/af505f6c26e0a988.css
+ /_next/static/chunks/63-c3a61b2e86b89625.js
+ /favicon.ico
+ /_next/static/chunks/polyfills-42372ed130431b0a.js
+ /_next/static/chunks/4bd1b696-5b6c0ccbd3c0c9ab.js
+ /vercel.svg
+ /_next/static/chunks/pages/_app-da15c11dea942c36.js
+ /_next/static/chunks/main-app-f622390711274b01.js
+ /globe.svg
+ /_next/static/chunks/app/_not-found/page-f08302ee705a96b1.js
+ /_next/static/media/747892c23ea88013-s.woff2
+ /_next/static/media/ba015fad6dcf6784-s.woff2
+ /_next/static/media/569ce4b8f30dc480-s.p.woff2
+ /cdn-cgi/_next_cache/BwDcaYnzzAsFa5A-aKJDS/favicon.ico.cache
+ /_next/static/chunks/684-c131fa2291503b5d.js
Uploaded 10 of 31 assets
Uploaded 20 of 31 assets
Uploaded 31 of 31 assets
✨ Success! Uploaded 31 files (3.67 sec)
Total Upload: 13878.56 KiB / gzip: 2299.71 KiB
Worker Startup Time: 20 ms
Your worker has access to the following bindings:
- Assets:
- Binding: ASSETS
Uploaded my-next-app (18.21 sec)
Deployed my-next-app triggers (1.02 sec)
https://my-next-app.fastapijapan.workers.dev
Current Version ID: e7c9a9ea-355b-4231-bf60-e5f27fa6ed9c
├ Waiting for DNS to propagate. This might take a few minutes.
│ DNS propagation complete.
│
├ Waiting for deployment to become available
│ deployment is ready at: https://my-next-app.fastapijapan.workers.dev
│
├ Opening browser
│
╰ Done
────────────────────────────────────────────────────────────────────────────────
🎉 SUCCESS Application deployed successfully!
🔍 View Project
Visit: https://my-next-app.fastapijapan.workers.dev
Dash: https://dash.cloudflare.com/?to=/:account/workers/services/view/my-next-app
💻 Continue Developing
Change directories: cd my-next-app
Start dev server: npm run dev
Deploy again: npm run deploy
📖 Explore Documentation
https://developers.cloudflare.com/workers
🐛 Report an Issue
https://github.com/cloudflare/workers-sdk/issues/new/choose
💬 Join our Community
https://discord.cloudflare.com
────────────────────────────────────────────────────────────────────────────────
途中で選択が出てきますので、ログを確認して選択してください。
基本はデフォルトですが、Do you want to deploy your application?
だけはyes
にしています。
こうすることで初回のデプロイを勝手にやってくれます。
デプロイをするとURLが表示されるので確認しましょう(表示まで少し時間がかかるかもです)
デプロイまでうまくいきました。
ここまでで一通り環境構築はできているのですが、Next.js 15だとWorkersと相性が悪い箇所があったのでバージョンをダウンさせます。
$ npm install react@18.2.0 react-dom@18.2.0 next@14.2.0 eslint-config-next@14.1.4
またこれに伴ってTailwindCSSも4系から3系にバージョンダウンします。
$ npm install -D tailwindcss@3 postcss autoprefixer
$ npx tailwindcss init -p
VSCodeを開いてTailwindCSSの設定をしていきます。
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
// Or if using `src` directory:
"./src/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-sans)'],
mono: ['var(--font-mono)'],
},
},
},
plugins: [],
};
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
:root {
--color-background: var(--background);
--color-foreground: var(--foreground);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
次にNext.jsの設定も変更します。実はデフォルトのnext.config.ts
ではエラーになってしまうのでnext.config.js
を作り直します。
$ rm next.config.ts
$ touch next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
/* config options here */
};
module.exports = nextConfig;
// added by create cloudflare to enable calling `getCloudflareContext()` in `next dev`
// Use dynamic import for ESM module
(async () => {
try {
const { initOpenNextCloudflareForDev } = await import("@opennextjs/cloudflare");
initOpenNextCloudflareForDev();
} catch (error) {
console.error("Error initializing OpenNext Cloudflare for dev:", error);
}
})();
最後に書かれているこの部分は今回デプロイに利用するopennext-cloudflare
のドキュメントで設定してくださいと書いてあるものです。この設定をすることで環境変数などがローカルのWorkers上で読み込めるようになります。
Cloudflareの開発にはいろいろな方法があります。例えばnext-on-page
はその1つです。
最近はopennextjs-cloudflareが推奨されているようで、cloudflareのテンプレートではデフォルト利用されるようになりました。
またLayoutのフォントもNext.js14にはなくてエラーになっているので直しておきます
import type { Metadata } from "next";
import { Inter, Roboto_Mono } from "next/font/google";
import "./globals.css";
// Next.js 14で利用可能なフォントに変更
const inter = Inter({
variable: "--font-sans",
subsets: ["latin"],
});
const robotoMono = Roboto_Mono({
variable: "--font-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${inter.variable} ${robotoMono.variable} antialiased`}>
{children}
</body>
</html>
);
}
それではTailwindCSSが効いてるかを確認しましょう
export default function Home() {
return (
<div>
<h1 className="text-3xl font-bold underline">Hello world!</h1>
</div>
);
}
$ npm run preview
http://localhost:8788を開いて以下の画面がでれば環境構築はうまくいっています。
ここでnpm run preview
をしたのでもう少し詳しく解説します。
このコマンドはpackage.json
で設定されたコマンドです。
Scriptsに書かれたコマンドはnpm run
+コマンドで右に書かれたコマンドを実行できます。
今回の場合はopennextjs-cloudflare build && opennextjs-cloudflare preview
というのが内部的には実行されています。
このopennextjs-cloudflare preview
をすることでPagesとWorkersを立ち上げて画面をチェックできるようになります。Reactの開発に慣れていたらnpm run dev
で開発しますが、Node.jsとは別の環境(Workersの環境)で立ち上がるのでその環境の上でしかこのあと使うD1は対応していません。
開発をするときはnpm run preview
でサーバーを起動する必要があります。
現時点でwranglerではホットリロードがうまくいきませんでした。
変更をしたらnpm run previewをする必要がありそうです
2. D1の初期設定
まずはデータベースの設定をしていきます。
Cloudflare D1を利用する場合、Wranglerを利用することで本番環境のDBとローカル環境のDB(SQLite)を切り替えることができます。
ローカルではファイルベースでDBが使えるSQLite
を細かい設定することなく利用できるのがWranglerで開発することのメリットです。(ただしnpm run devでは使えないのでwrangler devで起動します)
Wranglerコマンドから作成すれば初期設定などもすべて行ってくれるので早速作ってみましょう
$ npx wrangler d1 create file-share-app
⛅️ wrangler 4.7.2 (update available 4.10.0)
------------------------------------------------------
✅ Successfully created DB 'file-share-app' in region APAC
Created your new D1 database.
{
"d1_databases": [
{
"binding": "DB",
"database_name": "file-share-app",
"database_id": "22241865-2c1d-4be4-8683-5323b9489146"
}
]
}
作成できると接続情報が表示されるので、wrangler.jsonc
に本番の接続情報をコピペします。
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-next-app",
"main": ".open-next/worker.js",
"compatibility_date": "2025-03-01",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"binding": "ASSETS",
"directory": ".open-next/assets"
},
"observability": {
"enabled": true
},
"d1_databases": [
{
"binding": "DB",
"database_name": "file-share-app",
"database_id": "22241865-2c1d-4be4-8683-5323b9489146"
}
]
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" },
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
// "vars": { "MY_VARIABLE": "production_value" },
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
/**
* Static Assets
* https://developers.cloudflare.com/workers/static-assets/binding/
*/
// "assets": { "directory": "./public/", "binding": "ASSETS" },
/**
* Service Bindings (communicate between multiple Workers)
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
}
接続情報はそれぞれ違うのでターミナルに表示されたものを利用してください。
実際に本番のDBに接続できるかを確かめてみます。
SQLファイルを作成して本番環境とローカル環境で実行してみましょう
$ touch init.sql
DROP TABLE IF EXISTS posts;
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY,
title TEXT,
content TEXT
);
INSERT INTO posts (id, title, content) VALUES
(1, '初めての投稿', 'これは私の最初のブログ投稿です。'),
(2, '2番目の投稿', 'ブログを続けるのは楽しいです。'),
(3, '今日の出来事', '今日は晴れでした。');
それでは本番環境にむけて実行してみます。
$ npx wrangler d1 execute file-share-app --remote --file=./init.sql
⛅️ wrangler 4.7.2 (update available 4.10.0)
------------------------------------------------------
✔ ⚠️ This process may take some time, during which your D1 database will be unavailable to serve queries.
Ok to proceed? … yes
🌀 Executing on remote database file-share-app (22241865-2c1d-4be4-8683-5323b9489146):
🌀 To execute on your local development database, remove the --remote flag from your wrangler command.
Note: if the execution fails to complete, your DB will return to its original state and you can safely retry.
├ 🌀 Uploading 22241865-2c1d-4be4-8683-5323b9489146.3d1ed7e72baa5275.sql
│ 🌀 Uploading complete.
│
🌀 Starting import...
🌀 Processed 3 queries.
🚣 Executed 3 queries in 0.00 seconds (4 rows read, 5 rows written)
Database is currently at bookmark 00000001-00000005-00004ede-118373d5acaaccd611d7b5775ae69a13.
┌────────────────────────┬───────────┬──────────────┬────────────────────┐
│ Total queries executed │ Rows read │ Rows written │ Database size (MB) │
├────────────────────────┼───────────┼──────────────┼────────────────────┤
│ 3 │ 4 │ 5 │ 0.02 │
└────────────────────────┴───────────┴──────────────┴────────────────────┘
CloudflareのコンソールでDatabaseの「Tables」から「Posts」を確認するとデータが追加されています。
ここでのポイントは実行のコマンドに--remote
とつけていることです。
こうすることで本番環境に対してWranglerコマンドを実行しています。
$ npx wrangler d1 execute file-share-app --remote --command='SELECT * FROM posts'
⛅️ wrangler 4.7.2 (update available 4.10.0)
------------------------------------------------------
🌀 Executing on remote database file-share-app (22241865-2c1d-4be4-8683-5323b9489146):
🌀 To execute on your local development database, remove the --remote flag from your wrangler command.
🚣 Executed 1 command in 0.2074ms
┌────┬──────────────┬──────────────────────────────────┐
│ id │ title │ content │
├────┼──────────────┼──────────────────────────────────┤
│ 1 │ 初めての投稿 │ これは私の最初のブログ投稿です。 │
├────┼──────────────┼──────────────────────────────────┤
│ 2 │ 2番目の投稿 │ ブログを続けるのは楽しいです。 │
├────┼──────────────┼──────────────────────────────────┤
│ 3 │ 今日の出来事 │ 今日は晴れでした。 │
└────┴──────────────┴──────────────────────────────────┘
--command
でクエリを実行することも可能です。
続いてローカル環境でも確認してみましょう。
--remote
ではなく--local
とすることでローカルで勝手に設定してくれているSQLiteに接続してくれます。
$ npx wrangler d1 execute file-share-app --local --file=./init.sql
⛅️ wrangler 4.7.2 (update available 4.10.0)
------------------------------------------------------
🌀 Executing on local database file-share-app (22241865-2c1d-4be4-8683-5323b9489146) from .wrangler/state/v3/d1:
🌀 To execute on your remote database, add a --remote flag to your wrangler command.
🚣 3 commands executed successfully.
file-share-app on main [!?] via v22.4.0 took 2s
$ npx wrangler d1 execute file-share-app --local --command='SELECT * FROM posts'
⛅️ wrangler 4.7.2 (update available 4.10.0)
------------------------------------------------------
🌀 Executing on local database file-share-app (22241865-2c1d-4be4-8683-5323b9489146) from .wrangler/state/v3/d1:
🌀 To execute on your remote database, add a --remote flag to your wrangler command.
🚣 1 command executed successfully.
┌────┬──────────────┬──────────────────────────────────┐
│ id │ title │ content │
├────┼──────────────┼──────────────────────────────────┤
│ 1 │ 初めての投稿 │ これは私の最初のブログ投稿です。 │
├────┼──────────────┼──────────────────────────────────┤
│ 2 │ 2番目の投稿 │ ブログを続けるのは楽しいです。 │
├────┼──────────────┼──────────────────────────────────┤
│ 3 │ 今日の出来事 │ 今日は晴れでした。 │
└────┴──────────────┴──────────────────────────────────┘
3. Drizzleを設定する
今回はORM(データベースからデータを取得するのをJavaScriptで書く)としてDrizzle ORMを利用します。
Drizzleを使うことで型安全にデータ取得などが行えます。
$ npm install drizzle-orm
$ npm install -D drizzle-kit
まずはDrizzleを利用して今回利用するfiles
テーブルを作成します。
$ mkdir db
$ touch schema.ts
DrizzleはTypeScriptでテーブル定義を作成してマイグレーションによって、本番環境やローカル環境にテーブルの設定を反映されることができます。
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
import { randomUUID } from "crypto";
export const files = sqliteTable("files", {
id: text("id")
.primaryKey()
.$defaultFn(() => randomUUID()),
fileName: text("fileName").notNull(),
filePath: text("filePath").notNull(),
contentType: text("contentType").notNull(),
expiresAt: text("expiresAt").notNull(),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
});
テーブルの構造は以下のようになっています。
名前 | 内容 | 制約 |
---|---|---|
id | データの識別子 | 主キー、ランダムでデフォルトに入れる |
fileName | ファイル名 | 必須 |
filePath | ファイルの保存先のURL | 必須 |
contentType | ファイルの種類 | 必須 |
expireAt | ファイルのDL有効期限 | 必須 |
createdAt | 作成日時 | 必須かつ作成日時をデフォルトで入れる |
今回はギガファイルのクローンを作成するのでこのようなテーブル定義になっています。
次にDrizzleが接続するDBの設定を書きます。
$ touch drizzle.config.ts
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle/migrations",
schema: "./db/schema.ts",
dialect: "sqlite",
driver: "d1-http",
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
token: process.env.CLOUDFLARE_D1_TOKEN!,
},
});
環境変数の読み込みにdotenv
ライブラリを利用しているのでインストールをします
$ npm install dotenv
注目するべき箇所は大きく2つです。
schema: "./db/schema.ts",
この箇所で実際のスキーマ情報を参照するファイルを設定しています。先程作成したファイルです。
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
token: process.env.CLOUDFLARE_D1_TOKEN!,
},
ここでDBの接続情報を設定しています。これはまだないので.env
に追加しましょう
$ touch .env
CLOUDFLARE_ACCOUNT_ID=あなたのアカウントID
CLOUDFLARE_DATABASE_ID=あなたのデータベースID
CLOUDFLARE_D1_TOKEN=あなたのD1トークン
CLOUDFLARE_ACCOUNT_IDはCloudflareを開いてURLから確認できます。
URLのdash.cloudflare.com/アカウントID/....
となっています。
ここでは66b167936693af0cdc8b85c743d6d92a
がアカウントIDです。
CLOUDFLARE_DATABASE_IDは先程wrangler.jsonc
に貼り付けたものにあります。
"d1_databases": [
{
"binding": "DB",
"database_name": "file-share-app",
"database_id": "22241865-2c1d-4be4-8683-5323b9489146" // これ
}
CLOUDFLARE_D1_TOKENはAPIで利用するトークンなので 左メニューから「Workers&Pages」の「Workers&Pages」を選び → 「Compute(Workers)」の「Workers&Pages」を選択します。(日本語UIの場合は適宜同じ箇所をみてください)
「Create Token」をクリック
「Create Custom Token」の「Get started」をクリック
Name: file-share-app
Permissions: Acounnt D1 Edit
「Continue to Summary」→「Create Token」をクリック
API Tokenが表示されるのでコピーして.envに設定して下さい
それでは実際にデータを反映させます。反映はコマンドで行うためpackage.json
にコマンドを追加しておきましょう。
{
"name": "my-next-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
"db:generate": "npx drizzle-kit generate", // 追加
"db:push": "npx drizzle-kit migrate" // 追加
},
"dependencies": {
"drizzle-orm": "^0.41.0",
"next": "^14.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250406.0",
"@eslint/eslintrc": "^3",
"@opennextjs/cloudflare": "~1.0.0-beta.0 || ^1.0.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"drizzle-kit": "^0.30.6",
"eslint": "^9",
"eslint-config-next": "^14.2.0",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "^5",
"wrangler": "^4.7.2"
}
}
npm run db:push
とすることでnpx drizzle-kit migrate
が実行されます。
こうすることで本番環境にschama.tsの内容が反映されます。
$ npm run db:generate
$ npm run db:push
Cloudflare D1を確認するとテーブルfiles
が作成されています。
Drizzleではdb:generate
によってテーブルのスキーマに対応する型ファイルが生成され、その型ファイルを利用することで型安全に開発ができます。ここはあとで体験しましょう
データベースを変更したらこの2つのコマンドを実行すると覚えておけばOKです。
最後にローカルにもスキーマを反映させましょう。
ここで重要なのがdb:generate
で作成されたファイル名です。
ランダムで名前がつくので確認してから以下のコマンドを実行します。
$ npx wrangler d1 execute file-share-app --local --file=./drizzle/migrations/ファイル名
テストデータもこのあとのために入れておきます。
$ npx wrangler d1 execute file-share-app --local --command="INSERT INTO files (id, fileName, filePath, contentType, expiresAt, createdAt) VALUES ('file_123abc456def', 'sample_document.pdf', '/uploads/sample_document.pdf', 'application/pdf', '2023-05-12T15:10:00Z', '2023-04-12T15:10:00Z');"
$ npx wrangler d1 execute file-share-app --local --command='SELECT * FROM files'
⛅️ wrangler 4.7.2 (update available 4.10.0)
------------------------------------------------------
🌀 Executing on local database file-share-app (22241865-2c1d-4be4-8683-5323b9489146) from .wrangler/state/v3/d1:
🌀 To execute on your remote database, add a --remote flag to your wrangler command.
🚣 1 command executed successfully.
┌───────────────────┬─────────────────────┬──────────────────────────────┬─────────────────┬──────────────────────┬──────────────────────┐
│ id │ fileName │ filePath │ contentType │ expiresAt │ createdAt │
├───────────────────┼─────────────────────┼──────────────────────────────┼─────────────────┼──────────────────────┼──────────────────────┤
│ file_123abc456def │ sample_document.pdf │ /uploads/sample_document.pdf │ application/pdf │ 2023-05-12T15:10:00Z │ 2025304-12T15:10:00Z │
└───────────────────┴─────────────────────┴──────────────────────────────┴─────────────────┴──────────────────────┴──────────────────────┘
4. APIでテストデータをD1から取得する
ここからはAPI(Hono)を利用してD1のデータを取得できるようにします。
まずは簡単なAPIを作ってみます。
$ mkdir -p app/api/systems/ping
$ touch app/api/systems/ping/route.ts
export async function GET() {
return new Response("pong");
}
それでは起動してみましょう。
$ npm run preview
$ curl localhost:8787/api/systems/ping
pong
Next.jsではディレクトリの構造がそのままAPIのパスになります。
今回はapiディレクトリ/sytemsディレクトリ/pingディレクトリ
にあるので/api/systems/ping
というパスで叩くことができます。
中身はすごく単純でpong
とレスポンスを返しています。
export async function GET() {
return new Response("pong");
}
APIについてなんとなくわかったところでD1のデータを返すエンドポイントを試しに作ります。
このエンドポイントはD1の接続を確かめるために作成するため、実際には利用をしませんがこのあとの説明をしやすくするためにやります。
$ mkdir -p app/api/\[\[...route\]\]
$ touch app/api/\[\[...route\]\]/route.ts
ここからはHonoを利用していきます。
$ npm install hono
今回はディレクトリが[[...route]]
となっています。
先程の説明からするとパスが/api/[[...route]]
ということになりますが、これはroute.ts
の中で[[...route]]の部分をオプションにして可変的にできます。
HonoのAPIをみるとイメージが湧きやすいのでコードを書いていきます。
import { files } from "@/db/schema";
import { drizzle } from "drizzle-orm/d1";
import { Hono } from "hono";
import { handle } from "hono/vercel";
import { getCloudflareContext } from "@opennextjs/cloudflare";
const app = new Hono().basePath("/api");
app.get("/files", async (c) => {
const db = drizzle(
(getCloudflareContext().env as any).DB as unknown as D1Database
);
const filesResponse = await db.select().from(files);
return c.json(filesResponse);
});
export const GET = handle(app);
まずはHonoを使ってベースパスを作成します。
Honoを利用する場合は以前のようにディレクトリでのルーティングとは変わって定義する必要があります。
app
を利用する場合はすべて/api
からAPIのエンドポイントが始まります。
const app = new Hono().basePath("/api");
そして/files
を追加することで/api/files
でアクセスができます。
app.get("/files", async (c) => {
});
ここではfilesテーブルのデータをすべて取得します。
const db = drizzle(
(getCloudflareContext().env as any).DB as unknown as D1Database
);
まずはDrzzleを使ってDBに接続する処理です。今回は起動にopnennextjs-cloudflare
を利用するのでそこから環境変数を取ることができます。
DBというのはwrangler.jsonc
で設定したものを指しています。型は無理やり合わせています。
Drizzleを使ってデータを取得してレスポンスとして返しています。
const filesResponse = await db.select().from(files);
return c.json(filesResponse);
最後にHonoとNext.jsのAPIを統合させるための設定を書きます。
export const GET = handle(app);
起動はnpm run preview
です
$ npm run preview
$ curl localhost:8787/api/files
[{"id":"file_123abc456def","fileName":"sample_document.pdf","filePath":"/uploads/sample_document.pdf","contentType":"application/pdf","expiresAt":"2023-05-12T15:10:00Z","createdAt":"2025-04-12T15:10:00Z"}]
テストデータが返ってきました。
これで一通りCloudflareの理解ができたのでファイル共有アプリを開発していきましょう
5. ファイルをアップロードする
ファイルをアップロードしてデータベースに保存する機能を実装しましょう
ファイルをドラッグ・アンド・ドロップで追加できるようにしたいのでライブラリをインストールします。
$ npm install react-dropzone
"use client";
import React, { useState, useCallback } from "react";
import { useDropzone } from "react-dropzone";
type UploadResult = {
success: boolean;
message?: string;
url?: string;
expiresAt?: number;
};
export default function Home() {
const [file, setFile] = useState<File | null>(null);
const [fileName, setFileName] = useState("");
const [uploading, setUploading] = useState(false);
const [uploadResult, setUploadResult] = useState<UploadResult | null>(null);
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
const droppedFile = acceptedFiles[0];
setFile(droppedFile);
setFileName(droppedFile.name);
}
}, []);
const handleUpload = async () => {
if (!file) return;
setUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("expiration", "7");
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(
`Upload failed: ${response.status} ${response.statusText}`
);
}
const result = (await response.json()) as UploadResult;
setUploadResult(result);
if (result.success) {
setFile(null);
setFileName("");
}
} catch (error) {
setUploadResult({
success: false,
message:
error instanceof Error
? error.message
: "アップロード中にエラーが発生しました",
});
} finally {
setUploading(false);
}
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
return (
<div className="container mx-auto p-4 max-w-md">
<h1 className="text-2xl font-bold mb-4">ファイル共有アプリ</h1>
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-8 mb-4 text-center cursor-pointer transition-colors ${
isDragActive
? "border-blue-500 bg-blue-50"
: "border-gray-300 hover:border-gray-400"
}`}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center justify-center h-32">
<p className="text-gray-600">
{file ? fileName : "ここにファイルをドラッグ&ドロップ"}
</p>
</div>
</div>
{file && (
<div>
<p>ファイル名: {fileName}</p>
<button onClick={handleUpload} disabled={uploading}>
{uploading ? "アップロード中..." : "アップロード"}
</button>
</div>
)}
{uploadResult && uploadResult.success && uploadResult.url && (
<div>
<h3>共有URL:</h3>
<input
type="text"
readOnly
value={uploadResult.url}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
onClick={() => navigator.clipboard.writeText(uploadResult.url!)}
>
コピー
</button>
{uploadResult.expiresAt && (
<p>有効期限: {new Date(uploadResult.expiresAt).toLocaleString()}</p>
)}
</div>
)}
</div>
);
}
まだAPIを実装していないのでアップロードを押すとエラーになりますが、解説していきます。
まずはドラッグアンドドロップの機能からです。
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-8 mb-4 text-center cursor-pointer transition-colors ${
isDragActive
? "border-blue-500 bg-blue-50"
: "border-gray-300 hover:border-gray-400"
}`}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center justify-center h-32">
<p className="text-gray-600">
{file ? fileName : "ここにファイルをドラッグ&ドロップ"}
</p>
</div>
</div>
ここで{...getRootProps}
というのがあります。
これはドラッグアンドドロップを実現するライブラリから来ています。
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
getRootProps
はドロップゾーンに適用したいドラッグしたら実行されるイベントリスナーなど必要なすべてのPropsをdivに対して展開しています。
イメージはこのような感じになっています。
<div
onDragEnter={rootProps.onDragEnter}
onDragOver={rootProps.onDragOver}
onDragLeave={rootProps.onDragLeave}
onDrop={rootProps.onDrop}
onClick={rootProps.onClick}
onKeyDown={rootProps.onKeyDown}
tabIndex={rootProps.tabIndex}
role={rootProps.role}
aria-label={rootProps["aria-label"]}
data-testid={rootProps["data-testid"]}
className="border-2 border-dashed rounded-lg p-4 text-center"
>
getInputProps
は実際のファイル入力要素(input type="file")に適用すべきプロパティを返します。これも必要なPropsをgetRootPropsと同じ要領で用意してくれています。(onChangeなどのイベントが含まれます)
isDragActive
は現在ユーザーがファイルをドロップゾーン上にドラッグしているかどうかを示します。これはドロップゾーンの見た目を変更するのに便利です。
isDragActive
? "border-blue-500 bg-blue-50"
: "border-gray-300 hover:border-gray-400"
onDrop
はドラッグアンドドロップしたあとに実行される関数を入れます。
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
const droppedFile = acceptedFiles[0];
setFile(droppedFile);
setFileName(droppedFile.name);
}
}, []);
useCallback
とすることで再レンダリングの度に関数が作られないようにしています。
onDropは1度だけ作られればよいのですが、useCallbackがないとステートの変更やドラッグアンドドロップでファイルが変わる度に関数が再作成されてパフォーマンスが落ちてしまします。
acceptedFiles: File[]
とすることでFile
型の配列のみを受け取るようにしています。
もしファイルがあればその配列の最初のファイルをステートに入れています。
複数ファイルを選択できるのでこのように配列でファイルが来ています。
const [file, setFile] = useState<File | null>(null);
const [fileName, setFileName] = useState("");
ファイル自体とファイル名をステートとしてもっています。
ファイルが追加されるとアップロードボタンが現れます。
{file && (
<div>
<p>ファイル名: {fileName}</p>
<button onClick={handleUpload} disabled={uploading}>
{uploading ? "アップロード中..." : "アップロード"}
</button>
</div>
)}
アップロードボタンを押すとhandleUpload
が実行されます。
const handleUpload = async () => {
if (!file) return;
setUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("expiration", "7");
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(
`Upload failed: ${response.status} ${response.statusText}`
);
}
const result = (await response.json()) as UploadResult;
setUploadResult(result);
if (result.success) {
setFile(null);
setFileName("");
}
} catch (error) {
setUploadResult({
success: false,
message:
error instanceof Error
? error.message
: "アップロード中にエラーが発生しました",
});
} finally {
setUploading(false);
}
};
ファイルが存在するのであれば、formData
にファイルとダウンロード期限(7日固定)をいれてAPI(/api/files
)に対してPOSTリクエストしています。
try {
const formData = new FormData();
formData.append("file", file);
formData.append("expiration", "7");
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
ただしくAPIが叩けたらレスポンスをUploadResult
というステートに入れています。
const result = (await response.json()) as UploadResult;
setUploadResult(result);
UploadResutは以下のような形をしています。
type UploadResult = {
success: boolean;
message?: string;
url?: string;
expiresAt?: number;
};
(省略)
const [uploadResult, setUploadResult] = useState<UploadResult | null>(null);
success: ファイルのアップロードに成功したかどうか
message: アップロードに関するメッセージ(失敗したら理由をいれる)
url: ファイルをダウンロードするページのURL
expiresAt: ファイルダウンロードの期限
message, url, expiresAtは?がついているのでnullでも構いません。
例外エラーがあるとmessageにエラーをいれます。
成功した場合はファイルを保存しました。といれています。
} catch (error) {
return c.json(
{ success: false, message: "ファイルの保存に失敗しました" },
500
);
}
return c.json({ success: true, message: "ファイルを保存しました" });
ファイルのアップロードができたらダウンロード先のURLと有効期限を表示します。
{uploadResult && uploadResult.success && uploadResult.url && (
<div>
<h3>共有URL:</h3>
<input
type="text"
readOnly
value={uploadResult.url}
/>
<button
onClick={() => navigator.clipboard.writeText(uploadResult.url!)}
>
コピー
</button>
{uploadResult.expiresAt && (
<p>有効期限: {new Date(uploadResult.expiresAt).toLocaleString()}</p>
)}
</div>
)}
コピーボタンを押すとクリップボードにURLが保存されます。
<button
onClick={() => navigator.clipboard.writeText(uploadResult.url!)}
>
それではAPIも開発しましょう。今回はDBにデータだけ保存して画像の保存は一旦実装しないで進めます。
import { files } from "@/db/schema";
import { drizzle } from "drizzle-orm/d1";
import { Hono } from "hono";
import { handle } from "hono/vercel";
import { getCloudflareContext } from "@opennextjs
/cloudflare";
const app = new Hono().basePath("/api");
app.get("/files", async (c) => {
const db = drizzle(
(getCloudflareContext().env as any).DB as unknown as D1Database
);
const filesResponse = await db.select().from(files);
return c.json(filesResponse);
});
// 追加
app.post("/upload", async (c) => {
const formData = await c.req.formData();
const fileData = formData.get("file");
const expirationDays = Number(formData.get("expiration"));
if (!fileData) {
return c.json({ success: false, message: "ファイルがありません" }, 400);
}
const file = fileData as File;
const fileName = file.name;
const filePath = `uploads/${Date.now()}-${fileName}`;
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + expirationDays);
try {
const db = drizzle(
(getCloudflareContext().env as any).DB as unknown as D1Database
);
await db.insert(files).values({
fileName,
filePath,
contentType: file.type,
expiresAt: expiresAt.toISOString(),
});
} catch (error) {
return c.json(
{ success: false, message: "ファイルの保存に失敗しました" },
500
);
}
return c.json({ success: true, message: "ファイルを保存しました" });
});
export const GET = handle(app);
export const POST = handle(app); // 追加
まずはAPI/api/upload
に送られたformDataからファイルとダウンロード期限を取り出します。
app.post("/upload", async (c) => {
const formData = await c.req.formData();
const fileData = formData.get("file");
const expirationDays = Number(formData.get("expiration"));
DBに保存するファイル名、ファイルのダウンロードパス、期限を用意します。
const file = fileData as File;
const fileName = file.name;
const filePath = `uploads/${Date.now()}-${fileName}`;
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + expirationDays);
DrizzleでDBにレコードを保存します。
import { files } from "@/db/schema";
(省略)
await db.insert(files).values({
fileName,
filePath,
contentType: file.type,
expiresAt: expiresAt.toISOString(),
});
ここで要素が1つでも足りないとエラーになります。
それはfiles
をスキーマの型情報を使っているからです(npm run db:generateで生成)
このようにvaluesにホバーするとvaluesに入れる型をみれます。
POSTリクエストのエンドポイントができたので設定もしておきます。
export const POST = handle(app);
それではサーバーを再起動してAPIを叩いてみましょう
// サーバーを切る
$ npm run preview
$ curl localhost:8787/api/files
[{"id":"file_123abc456def","fileName":"sample_document.pdf","filePath":"/uploads/sample_document.pdf","contentType":"application/pdf","expiresAt":"2023-05-12T15:10:00Z","createdAt":"2023-04-12T15:10:00Z"},{"id":"d2dd43f3-5cfa-46c5-be8b-a15df095c438","fileName":"GIHdu28aAAAb1_R.jpeg","filePath":"uploads/1744516025414-GIHdu28aAAAb1_R.jpeg","contentType":"image/jpeg","expiresAt":"2025-04-20T03:47:05.414Z","createdAt":"2025-04-13T03:47:05.421Z"}]
ちゃんとアップロードしたファイルの情報DBに保存されていることがわかります。
次は実際にCloudflare R2を使って画像を保存してみましょう
6. 画像を保存する
ここからはCloudflare R2を利用して画像をストレージに保存します。
R2は初回時にクレジットカードの登録が必要になります。(今回は料金はかかりません)
ここから先を進める途中で求められますので登録をして進んでください。
Cloudflareを開いて左メニューから「R2 Object Storage」をクリックして「Create bucket」をクリック
Bucket Nameに「file-share-app」と入力して「Create Bucket」をクリック
これで準備はできたのでAPIに画像を保存する処理を追加しましょう
今回もWranglerを利用して本番環境とローカル環境でストレージを切り替えられるようにします
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-next-app",
"main": ".open-next/worker.js",
"compatibility_date": "2025-03-01",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"binding": "ASSETS",
"directory": ".open-next/assets"
},
"observability": {
"enabled": true
},
"d1_databases": [
{
"binding": "DB",
"database_name": "file-share-app",
"database_id": "22241865-2c1d-4be4-8683-5323b9489146"
}
],
"r2_buckets": [
{
"binding": "R2",
"bucket_name": "file-share-app"
}
]
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" },
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
// "vars": { "MY_VARIABLE": "production_value" },
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
/**
* Static Assets
* https://developers.cloudflare.com/workers/static-assets/binding/
*/
// "assets": { "directory": "./public/", "binding": "ASSETS" },
/**
* Service Bindings (communicate between multiple Workers)
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
}
r2_buckets
の部分を追加しました。
これだけでローカルと本番を分けることができます。
import { files } from "@/db/schema";
import { drizzle } from "drizzle-orm/d1";
import { Hono } from "hono";
import { handle } from "hono/vercel";
import { getCloudflareContext } from "@opennextjs/cloudflare";
import { desc } from "drizzle-orm";
const app = new Hono().basePath("/api");
app.get("/files", async (c) => {
const db = drizzle(
(getCloudflareContext().env as any).DB as unknown as D1Database
);
const filesResponse = await db.select().from(files);
return c.json(filesResponse);
});
app.post("/upload", async (c) => {
const formData = await c.req.formData();
const fileData = formData.get("file");
const expirationDays = Number(formData.get("expiration"));
if (!fileData) {
return c.json({ success: false, message: "ファイルがありません" }, 400);
}
const file = fileData as File;
const fileName = file.name;
const filePath = `uploads/${Date.now()}-${fileName}`;
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + expirationDays);
// 追加
try {
const r2 = (getCloudflareContext().env as any).R2 as unknown as R2Bucket;
await r2.put(filePath, file);
} catch (r2Error) {
return c.json(
{
success: false,
message: `File upload failed: ${r2Error}`,
},
500
);
}
// 手前に移動
const db = drizzle(
(getCloudflareContext().env as any).DB as unknown as D1Database
);
try {
await db.insert(files).values({
fileName,
filePath,
contentType: file.type,
expiresAt: expiresAt.toISOString(),
});
} catch (error) {
return c.json(
{ success: false, message: "ファイルの保存に失敗しました" },
500
);
}
// 追加
const insertRecord = await db
.select()
.from(files)
.orderBy(desc(files.createdAt))
.limit(1);
// 修正
return c.json({
success: true,
message: "ファイルを保存しました",
url: `${process.env.BASE_URL}/files/${insertRecord[0].id}`,
expiresAt: expiresAt.toISOString(),
});
});
export const GET = handle(app);
export const POST = handle(app);
R2を環境変数から呼び出し、put
に画像データを渡すだけで完了です。
try {
const r2 = (getCloudflareContext().env as any).R2 as unknown as R2Bucket;
await r2.put(filePath, file);
画像が保存できたらダウンロードリンクを表示したいのでレスポンスにurl
とexpiresAt
を追加します
const insertRecord = await db
.select()
.from(files)
.orderBy(desc(files.createdAt))
.limit(1);
return c.json({
success: true,
message: "ファイルを保存しました",
url: `${process.env.BASE_URL}/files/${insertRecord[0].id}`,
expiresAt: expiresAt.toISOString(),
});
URLにはDBのレコードのidをいれています。
こうすることでダウンロード先のURLに遷移したときにDBのidから保存したデータのレコードを検索することができます
ダウンロード先のURLはローカルならlocalhost:8787
、本番ならデプロイしたホストのURLになるので.env
に設定しておきます。
CLOUDFLARE_ACCOUNT_ID=あなたのアカウントID
CLOUDFLARE_DATABASE_ID=あなたのデータベースID
CLOUDFLARE_D1_TOKEN=あなたのD1トークン
BASE_URL=http://localhost:8787
ローカルで試してみましょう
$ npm run preview
画像を追加してアップロードを押すと、.wrangler/state/v3/r2/file-share-app/blobs
にファイルが作成されているはずです。
このファイルは実際の画像データとは形式が異なるのでデプロイして本番に保存されるかをみましょう
$ npm run deploy
画像をアップロードするとCloudflare R2に保存されていることがわかります
有効期限も表示されるようになりました
7. 有効期限を指定できるようにする
いまのままだと有効期限が7日で固定になっているので、変更できるようにしましょう
"use client";
import React, { useState, useCallback } from "react";
import { useDropzone } from "react-dropzone";
type UploadResult = {
success: boolean;
message?: string;
url?: string;
expiresAt?: number;
};
type ExpirationOption = 1 | 3 | 5 | 7; // 追加
export default function Home() {
const [file, setFile] = useState<File | null>(null);
const [fileName, setFileName] = useState("");
const [uploading, setUploading] = useState(false);
const [uploadResult, setUploadResult] = useState<UploadResult | null>(null);
const [expiration, setExpiration] = useState<ExpirationOption>(7); // 追加
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
const droppedFile = acceptedFiles[0];
setFile(droppedFile);
setFileName(droppedFile.name);
}
}, []);
const handleUpload = async () => {
if (!file) return;
setUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("expiration", String(expiration)); // 変更
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(
`Upload failed: ${response.status} ${response.statusText}`
);
}
const result = (await response.json()) as UploadResult;
setUploadResult(result);
if (result.success) {
setFile(null);
setFileName("");
}
} catch (error) {
setUploadResult({
success: false,
message:
error instanceof Error
? error.message
: "アップロード中にエラーが発生しました",
});
} finally {
setUploading(false);
}
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
return (
<div className="container mx-auto p-4 max-w-md">
<h1 className="text-2xl font-bold mb-4">ファイル共有アプリ</h1>
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-8 mb-4 text-center cursor-pointer transition-colors ${
isDragActive
? "border-blue-500 bg-blue-50"
: "border-gray-300 hover:border-gray-400"
}`}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center justify-center h-32">
<p className="text-gray-600">
{file ? fileName : "ここにファイルをドラッグ&ドロップ"}
</p>
</div>
</div>
{file && (
<div>
<p>ファイル名: {fileName}</p>
{/* 追加 */}
<div className="my-4">
<label className="block mb-2">
有効期限:
<select
value={expiration}
onChange={(e) => setExpiration(Number(e.target.value) as ExpirationOption)}
className="ml-2 p-1 border rounded"
>
<option value={1}>1日</option>
<option value={3}>3日</option>
<option value={5}>5日</option>
<option value={7}>7日</option>
</select>
</label>
</div>
<button
onClick={handleUpload}
disabled={uploading}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
>
{uploading ? "アップロード中..." : "アップロード"}
</button>
</div>
)}
{uploadResult && uploadResult.success && uploadResult.url && (
<div>
<h3>共有URL:</h3>
<input type="text" readOnly value={uploadResult.url} />
<button
onClick={() => navigator.clipboard.writeText(uploadResult.url!)}
>
コピー
</button>
{uploadResult.expiresAt && (
<p>有効期限: {new Date(uploadResult.expiresAt).toLocaleString()}</p>
)}
</div>
)}
</div>
);
}
有効期限を保持するステートを用意してAPIに送るように変更しました
const [expiration, setExpiration] = useState<ExpirationOption>(7);
(省略)
try {
const formData = new FormData();
formData.append("file", file);
formData.append("expiration", String(expiration));
実際にデータを保存してみます
1日でアップロードしたのでDBの値をみてみましょう
$ npx wrangler d1 execute file-share-app --local --command='SELECT * FROM files'
│ 50bbf88a-08c7-4f86-afc0-422114c94a34 │ GIHdu28aAAAb1_R.jpeg │ uploads/1744702441134-GIHdu28aAAAb1_R.jpeg │ image/jpeg │ 2025-04-16T07:34:01.134Z │ 2025-04-15T07:34:01.170Z │
└──────────────────────
UTC時間(つまり9時間足して1日後)に設定されていそうです。
8. 画像ダウンロードページの作成
ダウンロードページのURLは/files/id
にします。
idはDBのレコードidなのでここからレコードをみつけることができます。
$ mkdir -p app/files/\[id\]
$ touch app/files/\[id\]/page.tsx
$ touch app/files/\[id\]/client.tsx
今回はApp Routerを利用しているのでディレクトリ構成がそのままURLのパスになります。
[id]
の部分はなんでも入ります。/files/hoge
や/files/12345
などがこのパスに該当します。
[id]
とすることで処理の中でidの部分を取得することが可能です。
まずはクライアントサイドで実行するダウンロードボタンのコンポーネントを作成します。
"use client";
function FileDownloadClient({ fileId }: { fileId: string }) {
const handleDownload = () => {
window.location.href = `/api/download/${fileId}`;
};
return (
<div>
<button onClick={handleDownload}>ファイルをダウンロード</button>
</div>
);
}
export default FileDownloadClient;
onClickを利用するためにクライアントサイドで実装しています。
idからDBのレコードをみつけて有効期限を確認する処理はサーバーサイドで行います。
import FileDownloadClient from "./client";
export type FileInfo = {
id: string;
fileName: string;
filePath: string;
contentType: string;
expiresAt: string;
createdAt: string;
};
async function getFileInfo(fileId: string) {
const baseUrl = process.env.HOST_URL || "http://localhost:8787";
const response = await fetch(`${baseUrl}/api/files/${fileId}`);
return (await response.json()) as FileInfo;
}
export default async function Page(props: { params: { id: string } }) {
const params = await props.params;
const fileInfo = await getFileInfo(params.id);
if (!fileInfo) {
return <div>ファイルが見つかりませんでした</div>;
}
const now = new Date();
const expiresAt = new Date(fileInfo.expiresAt);
const isExpired = now > expiresAt;
if (isExpired) {
return <div>ファイルの有効期限切れ</div>;
}
return <FileDownloadClient fileId={fileInfo.id} />;
}
ページにアクセスしたらidからDBの情報を取得します
async function getFileInfo(fileId: string) {
const baseUrl = process.env.HOST_URL || "http://localhost:8787";
const response = await fetch(`${baseUrl}/api/files/${fileId}`);
return (await response.json()) as FileInfo;
}
export default async function Page(props: { params: { id: string } }) {
const params = await props.params;
const fileInfo = await getFileInfo(params.id);
このAPIは後で作ります
そのあとDBのレコードがなければエラーを表示します
if (!fileInfo) {
return <div>ファイルが見つかりませんでした</div>;
}
ファイルがあれば有効期限をチェックします。
const now = new Date();
const expiresAt = new Date(fileInfo.expiresAt);
const isExpired = now > expiresAt;
if (isExpired) {
return <div>ファイルの有効期限切れ</div>;
}
問題なければクライアントサイドで実装している先程のコンポーネントを表示します。
APIも実装しましょう
まずは/api/files/[id]
でファイルの情報を取得できるようなエンドポイントを作ります。
import { files } from "@/db/schema";
import { drizzle } from "drizzle-orm/d1";
import { Hono } from "hono";
import { handle } from "hono/vercel";
import { getCloudflareContext } from "@opennextjs/cloudflare";
import { desc, eq } from "drizzle-orm";
const app = new Hono().basePath("/api");
app.get("/files", async (c) => {
const db = drizzle(
(getCloudflareContext().env as any).DB as unknown as D1Database
);
const filesResponse = await db.select().from(files);
return c.json(filesResponse);
});
app.post("/upload", async (c) => {
const formData = await c.req.formData();
const fileData = formData.get("file");
const expirationDays = Number(formData.get("expiration"));
if (!fileData) {
return c.json({ success: false, message: "ファイルがありません" }, 400);
}
const file = fileData as File;
const fileName = file.name;
const filePath = `uploads/${Date.now()}-${fileName}`;
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + expirationDays);
try {
const r2 = (getCloudflareContext().env as any).R2 as unknown as R2Bucket;
await r2.put(filePath, file);
} catch (r2Error) {
return c.json(
{
success: false,
message: `File upload failed: ${r2Error}`,
},
500
);
}
const db = drizzle(
(getCloudflareContext().env as any).DB as unknown as D1Database
);
try {
await db.insert(files).values({
fileName,
filePath,
contentType: file.type,
expiresAt: expiresAt.toISOString(),
});
} catch (error) {
return c.json(
{ success: false, message: "ファイルの保存に失敗しました" },
500
);
}
const insertRecord = await db
.select()
.from(files)
.orderBy(desc(files.createdAt))
.limit(1);
return c.json({
success: true,
message: "ファイルを保存しました",
url: `${process.env.BASE_URL}/files/${insertRecord[0].id}`,
expiresAt: expiresAt.toISOString(),
});
});
// 追加
app.get("/files/:id", async (c) => {
const id = c.req.param("id");
const db = drizzle(
(getCloudflareContext().env as any).DB as unknown as D1Database
);
const file = await db.select().from(files).where(eq(files.id, id)).limit(1);
return c.json(file[0]);
});
idから検索をしてレコードを返すだけのシンプルな実装です。
Drizzleでは1件だけを返しても配列で返ってくるため0件目を返すようにしました
const file = await db.select().from(files).where(eq(files.id, id)).limit(1);
return c.json(file[0]);
続いてダウンロード用のエンドポイント/api/download/:id
を作ります
import { files } from "@/db/schema";
import { drizzle } from "drizzle-orm/d1";
import { Hono } from "hono";
import { handle } from "hono/vercel";
import { getCloudflareContext } from "@opennextjs/cloudflare";
import { desc, eq } from "drizzle-orm";
const app = new Hono().basePath("/api");
app.get("/files", async (c) => {
const db = drizzle(
(getCloudflareContext().env as any).DB as unknown as D1Database
);
const filesResponse = await db.select().from(files);
return c.json(filesResponse);
});
app.post("/upload", async (c) => {
const formData = await c.req.formData();
const fileData = formData.get("file");
const expirationDays = Number(formData.get("expiration"));
if (!fileData) {
return c.json({ success: false, message: "ファイルがありません" }, 400);
}
const file = fileData as File;
const fileName = file.name;
const filePath = `uploads/${Date.now()}-${fileName}`;
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + expirationDays);
try {
const r2 = (getCloudflareContext().env as any).R2 as unknown as R2Bucket;
await r2.put(filePath, file);
} catch (r2Error) {
return c.json(
{
success: false,
message: `File upload failed: ${r2Error}`,
},
500
);
}
const db = drizzle(
(getCloudflareContext().env as any).DB as unknown as D1Database
);
try {
await db.insert(files).values({
fileName,
filePath,
contentType: file.type,
expiresAt: expiresAt.toISOString(),
});
} catch (error) {
return c.json(
{ success: false, message: "ファイルの保存に失敗しました" },
500
);
}
const insertRecord = await db
.select()
.from(files)
.orderBy(desc(files.createdAt))
.limit(1);
return c.json({
success: true,
message: "ファイルを保存しました",
url: `${process.env.BASE_URL}/files/${insertRecord[0].id}`,
expiresAt: expiresAt.toISOString(),
});
});
app.get("/files/:id", async (c) => {
const id = c.req.param("id");
const db = drizzle(
(getCloudflareContext().env as any).DB as unknown as D1Database
);
const file = await db.select().from(files).where(eq(files.id, id)).limit(1);
return c.json(file[0]);
});
// 追加
app.get("/download/:id", async (c) => {
try {
const id = c.req.param("id");
const db = drizzle(
(getCloudflareContext().env as any).DB as unknown as D1Database
);
const fileResults = await db
.select()
.from(files)
.where(eq(files.id, id))
.limit(1);
if (fileResults.length === 0) {
return c.json({ error: "ファイルが見つかりません" }, 404);
}
const fileInfo = fileResults[0];
if (new Date() > new Date(fileInfo.expiresAt)) {
return c.json({ error: "ファイルの有効期限が切れています" }, 410); // Gone
}
const r2 = (getCloudflareContext().env as any).R2 as unknown as R2Bucket;
const r2Object = await r2.get(fileInfo.filePath);
if (r2Object === null) {
return c.json({ error: "ストレージ上にファイルが見つかりません" }, 404);
}
const arrayBuffer = await r2Object.arrayBuffer();
c.header(
"Content-Disposition",
`attachment; filename="${fileInfo.fileName}"`
);
c.header(
"Content-Type",
fileInfo.contentType || "application/octet-stream"
);
c.header("Content-Length", String(arrayBuffer.byteLength));
return c.body(arrayBuffer);
} catch (error) {
console.error("ダウンロードエラー:", error);
return c.json(
{ error: "ファイルのダウンロード中にエラーが発生しました" },
500
);
}
});
export const GET = handle(app);
export const POST = handle(app);
まずはidからレコードを見つけて有効期限を確認します
const fileResults = await db
.select()
.from(files)
.where(eq(files.id, id))
.limit(1);
if (fileResults.length === 0) {
return c.json({ error: "ファイルが見つかりません" }, 404);
}
const fileInfo = fileResults[0];
if (new Date() > new Date(fileInfo.expiresAt)) {
return c.json({ error: "ファイルの有効期限が切れています" }, 410); // Gone
}
次にR2からレコードにあるファイルパスを利用してデータを取得します。
const r2 = (getCloudflareContext().env as any).R2 as unknown as R2Bucket;
const r2Object = await r2.get(fileInfo.filePath);
if (r2Object === null) {
return c.json({ error: "ストレージ上にファイルが見つかりません" }, 404);
}
const arrayBuffer = await r2Object.arrayBuffer();
ダウンロードボタンを押したら強制的にブラウザでダウンロードできるようにヘッダーを設定して返却します。
c.header(
"Content-Disposition",
`attachment; filename="${fileInfo.fileName}"`
);
c.header(
"Content-Type",
fileInfo.contentType || "application/octet-stream"
);
c.header("Content-Length", String(arrayBuffer.byteLength));
return c.body(arrayBuffer);
実際に試してみましょう。ローカルのDBのデータを一度消しておきます。
$ npx wrangler d1 execute file-share-app --local --command='DELETE FROM "files";'
$ npm run preview
ファイルをアップロードしたらリンクにアクセスします
ファイルをダウンロードをクリックすると保存がされました
9. スタイリング
最後にスタイリングを行っていきます。
TailwindCSS
で行いますので、気になる箇所は調べてみてください。
"use client";
import React, { useState, useCallback } from "react";
import { useDropzone } from "react-dropzone";
type UploadResult = {
success: boolean;
message?: string;
url?: string;
expiresAt?: number;
};
type ExpirationOption = 1 | 3 | 5 | 7;
export default function Home() {
const [file, setFile] = useState<File | null>(null);
const [fileName, setFileName] = useState("");
const [uploading, setUploading] = useState(false);
const [uploadResult, setUploadResult] = useState<UploadResult | null>(null);
const [expiration, setExpiration] = useState<ExpirationOption>(7);
const [copyStatus, setCopyStatus] = useState<"idle" | "copied">("idle");
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
const droppedFile = acceptedFiles[0];
setFile(droppedFile);
setFileName(droppedFile.name);
}
}, []);
const handleUpload = async () => {
if (!file) return;
setUploading(true);
setUploadResult(null);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("expiration", String(expiration));
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(
`Upload failed: ${response.status} ${response.statusText}`
);
}
const result = (await response.json()) as UploadResult;
setUploadResult(result);
if (result.success) {
setFile(null);
setFileName("");
}
} catch (error) {
setUploadResult({
success: false,
message:
error instanceof Error
? error.message
: "アップロード中にエラーが発生しました",
});
} finally {
setUploading(false);
}
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
return (
<div className="max-w-2xl mx-auto my-8">
<h1 className="text-2xl font-bold mb-6 text-center">
ファイル共有アプリ
</h1>
<div className="border border-[#e0e0e0] rounded shadow-sm overflow-hidden">
<div className="p-4 bg-[#f5f7fa] border-b border-[#3498db]">
<div className="flex flex-wrap gap-2">
<button
className={`px-3 py-1 rounded text-sm ${
expiration === 1
? "bg-[#e74c3c] text-white"
: "bg-[#ecf0f1] text-[#2c3e50]"
}`}
onClick={() => setExpiration(1)}
>
1日
</button>
<button
className={`px-3 py-1 rounded text-sm ${
expiration === 3
? "bg-[#e74c3c] text-white"
: "bg-[#ecf0f1] text-[#2c3e50]"
}`}
onClick={() => setExpiration(3)}
>
3日
</button>
<button
className={`px-3 py-1 rounded text-sm ${
expiration === 5
? "bg-[#e74c3c] text-white"
: "bg-[#ecf0f1] text-[#2c3e50]"
}`}
onClick={() => setExpiration(5)}
>
5日
</button>
<button
className={`px-3 py-1 rounded text-sm ${
expiration === 7
? "bg-[#e74c3c] text-white"
: "bg-[#ecf0f1] text-[#2c3e50]"
}`}
onClick={() => setExpiration(7)}
>
7日
</button>
</div>
</div>
<div
{...getRootProps()}
className={`p-6 bg-[#f8f9fa] text-center ${
isDragActive ? "bg-[#e3f2fd]" : ""
}`}
>
<input {...getInputProps()} />
<p className="mb-2">
ここにファイルをドラッグ&ドロップしてください。
</p>
<p className="text-sm text-gray-600 mb-2">
お使いのブラウザが対応していれば
<br />
フォルダごとドラッグすることが可能です。
</p>
<p className="text-sm text-gray-600">
1ファイル300GBまで、個数無制限
</p>
</div>
<div className="p-4 bg-white">
{file && (
<div className="mb-4">
<div className="flex items-center mb-2">
<label className="w-40 text-sm">ファイル名:</label>
<input
type="text"
value={fileName}
onChange={(e) => setFileName(e.target.value)}
className="flex-1 border border-gray-300 px-2 py-1 text-sm"
/>
</div>
<div className="flex items-center mt-4">
<button
className="bg-[#2ecc71] text-white px-4 py-1 text-sm rounded"
onClick={handleUpload}
disabled={uploading}
>
{uploading ? "アップロード中..." : "アップロード"}
</button>
</div>
</div>
)}
{uploadResult && uploadResult.success && uploadResult.url && (
<div className="mt-4 p-3 bg-[#e3f2fd] rounded">
<p className="text-sm font-medium mb-2">共有URL:</p>
<div className="flex">
<input
type="text"
readOnly
value={uploadResult.url}
className="flex-1 border border-gray-300 px-2 py-1 text-sm"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
className={`ml-2 ${
copyStatus === "copied" ? "bg-[#2ecc71]" : "bg-[#3498db]"
} text-white px-3 py-1 text-sm rounded transition-colors duration-300`}
onClick={() => {
navigator.clipboard.writeText(uploadResult.url!);
setCopyStatus("copied");
setTimeout(() => setCopyStatus("idle"), 2000);
}}
>
{copyStatus === "copied" ? "コピー完了" : "コピー"}
</button>
</div>
{uploadResult.expiresAt && (
<p className="mt-2 text-sm text-gray-500">
有効期限: {new Date(uploadResult.expiresAt).toLocaleString()}
</p>
)}
</div>
)}
</div>
</div>
</div>
);
}
"use client";
import Link from "next/link";
function FileDownloadClient({ fileId }: { fileId: string }) {
const handleDownload = () => {
window.location.href = `/api/download/${fileId}`;
};
return (
<div className="p-8 max-w-md mx-auto">
<h1 className="text-2xl font-bold mb-6">ファイルをダウンロード</h1>
<div className="p-4 bg-green-50 border border-green-200 rounded mb-6">
<button
onClick={handleDownload}
className="block w-full bg-blue-500 text-white text-center py-2 px-4 rounded hover:bg-blue-600"
>
ファイルをダウンロード
</button>
</div>
<div className="text-center text-sm text-gray-500">
<p>このファイルは指定された期間後に自動的に期限切れになります。</p>
<div className="mt-6">
<Link href="/" className="text-blue-500 hover:underline">
新しいファイルをアップロード
</Link>
</div>
</div>
</div>
);
}
export default FileDownloadClient;
いい感じになりました!これにて終了です。
スキルを定着させる挑戦課題
問題1 : 本番環境でファイル共有アプリが正しく動くように設定をしてください
問題2 : 複数ファイルをzipに圧縮してアップロードできるようにしてください
問題3 : ファイルの有効期限が切れたときの表示もスタイリングする
ここまで行えればCloudflareに関してはおおよそ理解ができるようになります
おわりに
今回はCloudflareを利用してファイル共有アプリを開発しました。
Wranglerが少し難しかったですが、雰囲気はつかめたかと思います。
テキストでは細かく解説しましたが、一部説明しきれていない箇所もありますので動画教材で紹介しています。合わせてご利用ください
図解ハンズオンたくさん投稿しています!
本記事のレビュアーの皆様
次回のハンズオンのレビュアーはXにて募集します。
参考