24
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【図解解説】Cloudflareをマスター!2時間でNext.jsを開発して学ぶチュートリアル【初心者OK】

Last updated at Posted at 2025-05-25

thumbnail.png

はじめに

こんにちは、Watanabe Jin(@Sicut_study)です。

AIが日々進化しており、個人開発を誰もが気軽にできるような世界になってきました。
私もかなり焦りながら勉強をしておりついていくのに必死です。

個人開発のハードルが下がった中で、日々多くの個人開発がリリースされています。
その中でNext.js × Hono × Cloudflareという構成をネット上で見る機会が増えました。

この構成をすることは個人開発者にとっては大きなメリットがあります。

  • (だいたい)無料枠でサービスが動かせる
  • 簡単にデプロイすることができる
  • Honoと相性が良いので軽量で高速なAPIが使える

ユーザー体験をより向上させながら、ほぼコスト無しで個人開発ができるのは大変魅力的です。Cloudflareは個人開発をしていくなら絶対に抑えておきたいスキルとなっています。

しかし、このチュートリアルを作りながらCloudflareについてまとまって解説がされているものが少ないことに気づきました。2025年4月現在の時点ではある程度開発になれている人が扱えるようなサービスといった印象を持っています。

そこで本チュートリアルでは初心者の人でもCloudflareを使えるように個人開発で利用するであろう主要なサービス「Cloudflare Pages」「Cloudflare Workers」「Cloudflare D1」「Cloudflare R2」をすべて利用してファイル共有サービスを開発します。

名称未設定のデザイン (4).gif

網羅的に学べる教材は世界でも少ないのでぜひともCloudflareを学ぶために本チュートリアルを行っていただけたら嬉しいです。

動画教材も用意しています

こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材でわからない細かい箇所があれば動画も活用ください

対象者

  • Next.jsを学んでみたい人
  • Cloudflareを体系的に学びたい人
  • 個人開発をしたい人
  • アプリを作りながら学びたい人

Reactの基本的な仕組みを理解している人であれば2時間程度で学習することが可能です

What is Cloudflare?

image.png

Cloudflareは、ウェブサイトやアプリケーションのパフォーマンスを向上させ、セキュリティを強化するためのサービスです。最近では単なるセキュリティやCDNサービスを超えて、サーバーレスコンピューティングやホスティングなどの領域にも拡大しています。

今回は多くのサービスの中でもNext.jsの開発をする上で抑えておきたい

  • Cloudflare Pages
  • Cloudflare Workers
  • Cloudflare D1
  • Cloudflare R2

すべて活用してファイル共有アプリケーションを作成していきます。

なぜCloudflareが人気なのか?

image.png

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サービス

image.png

Next.jsでアプリケーションを開発する中で、利用頻度が高いものをまとめてハンズオンの中で学べるように設計しています。

これ1本やるだけでCloudflareの必須項目を理解することができます。

またCloudflare Workersと同等のサーバーレス環境で開発する「Wrangler」も利用します。

image.png

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」をクリッして完了です。

image.png

ここまでいったらテンプレートを利用して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が表示されるので確認しましょう(表示まで少し時間がかかるかもです)

image.png

デプロイまでうまくいきました。

ここまでで一通り環境構築はできているのですが、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の設定をしていきます。

tailwindconfig.js
/** @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: [],
};
app/global.css
@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
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にはなくてエラーになっているので直しておきます

image.png

app/layout.tsx
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が効いてるかを確認しましょう

app/page.tsx
export default function Home() {
  return (
    <div>
      <h1 className="text-3xl font-bold underline">Hello world!</h1>
    </div>
  );
}
$ npm run preview

http://localhost:8788を開いて以下の画面がでれば環境構築はうまくいっています。

image.png

ここでnpm run previewをしたのでもう少し詳しく解説します。
このコマンドはpackage.jsonで設定されたコマンドです。

image.png

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に本番の接続情報をコピペします。

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
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」を確認するとデータが追加されています。

image.png

ここでのポイントは実行のコマンドに--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でテーブル定義を作成してマイグレーションによって、本番環境やローカル環境にテーブルの設定を反映されることができます。

db/schema.ts
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
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
.env
CLOUDFLARE_ACCOUNT_ID=あなたのアカウントID
CLOUDFLARE_DATABASE_ID=あなたのデータベースID
CLOUDFLARE_D1_TOKEN=あなたのD1トークン

CLOUDFLARE_ACCOUNT_IDはCloudflareを開いてURLから確認できます。

image.png

URLのdash.cloudflare.com/アカウントID/....となっています。
ここでは66b167936693af0cdc8b85c743d6d92aがアカウントIDです。

CLOUDFLARE_DATABASE_IDは先程wrangler.jsoncに貼り付けたものにあります。

wrangler.jsonrc
  "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の場合は適宜同じ箇所をみてください)

image.png

image.png

「Create Token」をクリック

image.png

「Create Custom Token」の「Get started」をクリック

image.png

Name: file-share-app
Permissions: Acounnt D1 Edit

「Continue to Summary」→「Create Token」をクリック

image.png

API Tokenが表示されるのでコピーして.envに設定して下さい

image.png

それでは実際にデータを反映させます。反映はコマンドで行うためpackage.jsonにコマンドを追加しておきましょう。

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が作成されています。

image.png

Drizzleではdb:generateによってテーブルのスキーマに対応する型ファイルが生成され、その型ファイルを利用することで型安全に開発ができます。ここはあとで体験しましょう

データベースを変更したらこの2つのコマンドを実行すると覚えておけばOKです。

最後にローカルにもスキーマを反映させましょう。

ここで重要なのがdb:generateで作成されたファイル名です。

image.png

ランダムで名前がつくので確認してから以下のコマンドを実行します。

$ 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
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をみるとイメージが湧きやすいのでコードを書いていきます。

app/api/[[...route]]/route.ts
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
app/page.tsx
"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>
  );
}

image.png

まだ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にデータだけ保存して画像の保存は一旦実装しないで進めます。

app/api/[[...route]]/route.ts
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で生成)

image.png

このように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」をクリック

image.png

Bucket Nameに「file-share-app」と入力して「Create Bucket」をクリック

image.png

これで準備はできたのでAPIに画像を保存する処理を追加しましょう
今回もWranglerを利用して本番環境とローカル環境でストレージを切り替えられるようにします

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"
    }
  ],
  "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の部分を追加しました。
これだけでローカルと本番を分けることができます。

src/api/[[...route]]/route.ts
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);

画像が保存できたらダウンロードリンクを表示したいのでレスポンスにurlexpiresAtを追加します

  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に設定しておきます。

.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にファイルが作成されているはずです。

image.png

このファイルは実際の画像データとは形式が異なるのでデプロイして本番に保存されるかをみましょう

$ npm run deploy

画像をアップロードするとCloudflare R2に保存されていることがわかります

image.png

有効期限も表示されるようになりました

image.png

7. 有効期限を指定できるようにする

いまのままだと有効期限が7日で固定になっているので、変更できるようにしましょう

app/page.tsx
"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));

実際にデータを保存してみます

image.png

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の部分を取得することが可能です。

まずはクライアントサイドで実行するダウンロードボタンのコンポーネントを作成します。

app/files/[id]/client.tsx
"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のレコードをみつけて有効期限を確認する処理はサーバーサイドで行います。

app/files/[id]/page.tsx
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]でファイルの情報を取得できるようなエンドポイントを作ります。

app/api/[[...route]]/route.ts
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を作ります

app/api/[[...route]]/route.ts
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

ファイルをアップロードしたらリンクにアクセスします

image.png

ファイルをダウンロードをクリックすると保存がされました

image.png

9. スタイリング

最後にスタイリングを行っていきます。
TailwindCSSで行いますので、気になる箇所は調べてみてください。

app/page.tsx
"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>
  );
}
app/files/[id]/client.tsx
"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;

image.png

image.png

image.png

いい感じになりました!これにて終了です。

スキルを定着させる挑戦課題

問題1 : 本番環境でファイル共有アプリが正しく動くように設定をしてください
問題2 : 複数ファイルをzipに圧縮してアップロードできるようにしてください
問題3 : ファイルの有効期限が切れたときの表示もスタイリングする

ここまで行えればCloudflareに関してはおおよそ理解ができるようになります

おわりに

今回はCloudflareを利用してファイル共有アプリを開発しました。
Wranglerが少し難しかったですが、雰囲気はつかめたかと思います。

テキストでは細かく解説しましたが、一部説明しきれていない箇所もありますので動画教材で紹介しています。合わせてご利用ください

図解ハンズオンたくさん投稿しています!

本記事のレビュアーの皆様

次回のハンズオンのレビュアーはXにて募集します。

参考

24
9
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
24
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?