Skip to content
Cloud & Infra Intermediate Tutorial

Ship a Full-Stack App to Cloudflare Pages, Workers, and D1 in One Deploy

Wire a Vite/React frontend to a Hono API backed by D1 SQLite, then ship everything in a single wrangler pages deploy.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Jun 26, 2026 · 7 min read
Ship a Full-Stack App to Cloudflare Pages, Workers, and D1 in One Deploy

What you'll build

A Vite/React frontend wired to a Hono REST API backed by a D1 serverless SQLite database. Everything deploys as a single Cloudflare Pages project: one wrangler pages deploy ships static assets, edge Functions, and seeds your database schema.

Prerequisites

  • Node.js 18+, npm 9+
  • A Cloudflare account (free tier works)
  • Wrangler 3.x: npm install -g wrangler
  • Basic familiarity with React and REST APIs

Log in before starting:

wrangler login

1. Scaffold the project

npm create vite@latest my-edge-app -- --template react-ts
cd my-edge-app
npm install
npm install hono
npm install -D @cloudflare/workers-types

No Vite proxy config needed. In production the frontend and API share the same origin, so /api/* fetch calls resolve correctly without any rewrites.

2. Create the D1 database

wrangler d1 create edge-db

Copy the database_id from the output. Then write a schema file:

-- schema.sql
CREATE TABLE IF NOT EXISTS notes (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  body       TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

INSERT INTO notes (body) VALUES ('Hello from D1');

Seed both local dev and production:

wrangler d1 execute edge-db --local --file=./schema.sql
wrangler d1 execute edge-db --remote --file=./schema.sql

Wrangler 3.x requires an explicit --local or --remote flag; omitting both is an error. Local state lands in .wrangler/state/v3/d1/ and is picked up automatically by wrangler pages dev.

3. Configure wrangler.toml

Create wrangler.toml at the project root, pasting in the database_id from step 2:

name = "my-edge-app"
pages_build_output_dir = "./dist"
compatibility_date = "2024-09-23"

[[d1_databases]]
binding = "DB"
database_name = "edge-db"
database_id = "YOUR_DATABASE_ID"

pages_build_output_dir is what marks this as a Pages project rather than a standalone Worker. Wrangler reads it during both local dev and deploy.

4. Write the Hono API

Create functions/api/[[route]].ts at the project root. The [[route]] catch-all routes every request under /api/* to this file.

// functions/api/[[route]].ts
/// <reference types="@cloudflare/workers-types" />
import { Hono } from 'hono'
import { handle } from 'hono/cloudflare-pages'

type Bindings = { DB: D1Database }

const app = new Hono<{ Bindings: Bindings }>()

app.get('/api/notes', async (c) => {
  const { results } = await c.env.DB
    .prepare('SELECT * FROM notes ORDER BY created_at DESC')
    .all()
  return c.json(results)
})

app.post('/api/notes', async (c) => {
  const { body } = await c.req.json<{ body: string }>()
  const { meta } = await c.env.DB
    .prepare('INSERT INTO notes (body) VALUES (?)')
    .bind(body)
    .run()
  return c.json({ id: meta.last_row_id }, 201)
})

export const onRequest = handle(app)

handle from hono/cloudflare-pages converts the Hono app into the onRequest export that Pages Functions expect. The triple-slash reference pulls in D1Database and the rest of the Workers globals from the dev dependency you installed in step 1.

5. Update the React frontend

Replace src/App.tsx:

import { useEffect, useState } from 'react'

type Note = { id: number; body: string; created_at: string }

export default function App() {
  const [notes, setNotes] = useState<Note[]>([])
  const [input, setInput] = useState('')

  const load = () =>
    fetch('/api/notes').then((r) => r.json()).then(setNotes)

  useEffect(() => { load() }, [])

  const add = async () => {
    if (!input.trim()) return
    await fetch('/api/notes', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ body: input }),
    })
    setInput('')
    load()
  }

  return (
    <main>
      <h1>Edge Notes</h1>
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <button onClick={add}>Add</button>
      <ul>{notes.map((n) => <li key={n.id}>{n.body}</li>)}</ul>
    </main>
  )
}

6. Scripts and local dev

Update the scripts block in package.json:

"scripts": {
  "dev:vite": "vite",
  "dev": "wrangler pages dev --proxy 5173",
  "build": "vite build",
  "deploy": "npm run build && wrangler pages deploy"
}

Local dev needs two terminals running concurrently:

# terminal 1 — Vite frontend HMR
npm run dev:vite

# terminal 2 — Pages Functions + D1
npm run dev

wrangler pages dev --proxy 5173 starts a local server (default port 8788), proxies static-asset requests to Vite, and runs your Functions against the local D1 state. Open http://localhost:8788.

7. Deploy

npm run deploy

Vite builds the React app to dist/, then Wrangler bundles the Functions and uploads everything. The output includes your assigned .pages.dev URL.

Verify it works

curl https://my-edge-app.pages.dev/api/notes

Expected:

[{"id":1,"body":"Hello from D1","created_at":"2024-..."}]

Post a note:

curl -X POST https://my-edge-app.pages.dev/api/notes \
  -H 'Content-Type: application/json' \
  -d '{"body":"deployed at the edge"}'

Expected: {"id":2}

In the Cloudflare dashboard, go to Workers & Pages > my-edge-app > Functions and confirm the invocations are logged.

Troubleshooting

Cannot read properties of undefined (reading 'DB') at runtime — The binding value in wrangler.toml must exactly match the key used as c.env.DB. Binding names are case-sensitive. Also confirm database_id is your real production ID, not the placeholder.

Cannot find module 'hono/cloudflare-pages' — The Cloudflare Pages adapter ships in Hono 3.x+. Run npm install hono@latest to update, then clear your node_modules/.cache.

wrangler pages dev returns 404 on /api/* — Wrangler looks for functions/ relative to where you run the command. Run it from the project root, and make sure the directory is named exactly functions (not api, not src/functions).

TypeScript still complains about D1Database after adding the reference — Check that @cloudflare/workers-types is actually in node_modules (npm ls @cloudflare/workers-types). If your editor uses the Vite tsconfig exclusively, add a separate functions/tsconfig.json that extends the root config and adds "types": ["@cloudflare/workers-types"] to compilerOptions.

Next steps

  • Use wrangler d1 migrations create edge-db add-column to manage schema changes via versioned SQL files instead of raw execute calls.
  • Add Cloudflare KV for session or cache storage with the same [[kv_namespaces]] binding pattern in wrangler.toml.
  • Use Wrangler environments ([env.staging]) with a separate database_id to maintain isolated databases per environment.
  • The Cloudflare Pages Functions docs cover middleware, per-route bindings, and scheduled triggers for background jobs.
Ji-ho Choi
Written by
Ji-ho Choi · Security & Cloud Editor

Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading