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.
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-columnto manage schema changes via versioned SQL files instead of rawexecutecalls. - Add Cloudflare KV for session or cache storage with the same
[[kv_namespaces]]binding pattern inwrangler.toml. - Use Wrangler environments (
[env.staging]) with a separatedatabase_idto maintain isolated databases per environment. - The Cloudflare Pages Functions docs cover middleware, per-route bindings, and scheduled triggers for background jobs.
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
No comments yet
Be the first to weigh in.