Full-Stack App
Build the Webhook Hub dashboard - a React SPA with a Hono API backend, bundled with Vite and deployed as Workers Static Assets. The dashboard lists recent webhooks, source configurations, and delivery logs. It uses polling for updates; the WebSocket upgrade via Durable Objects is covered in the Durable Objects deep-dive.
Prerequisites: Platform Model, D1 CRUD, Queues
Scaffold the Project
npm create cloudflare@latest webhook-hub-dashboard -- --framework=react
cd webhook-hub-dashboard
This scaffolds a React + Vite project pre-configured for Cloudflare. Install the additional dependencies:
npm install hono
Project Structure
webhook-hub-dashboard/
├── src/
│ ├── App.tsx # React SPA
│ ├── main.tsx # React entry point
│ └── api/
│ └── index.ts # Hono API (server-side)
├── public/ # Static assets
├── vite.config.ts # Vite + Cloudflare plugin
├── wrangler.jsonc # Worker config
└── worker-configuration.d.ts
Configure Vite
The Cloudflare Vite plugin handles both dev and production. It runs your Worker locally during vite dev and builds optimized static assets for deployment.
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { cloudflare } from "@cloudflare/vite-plugin";
export default defineConfig({
plugins: [react(), cloudflare()],
});
That’s it. The plugin reads your wrangler.jsonc and wires everything up.
Configure wrangler.jsonc
{
"name": "webhook-hub-dashboard",
"main": "src/api/index.ts",
"compatibility_date": "2025-01-01",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [
{
"binding": "DB",
"database_name": "webhook-hub-db",
"database_id": "<your-database-id>"
}
],
"assets": {
"directory": "./dist/client",
"binding": "ASSETS",
"not_found_handling": "single-page-application",
"run_worker_first": ["/api/*"]
}
}
Key settings:
mainpoints to the Hono API serverassets.directoryis where Vite outputs the built React appnot_found_handling: "single-page-application"servesindex.htmlfor client-side routesrun_worker_first: ["/api/*"]routes API calls through the Worker; all other requests serve static files directly
Build the API
Create a Hono server that exposes the Webhook Hub data:
// src/api/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
const app = new Hono<{ Bindings: Env }>();
app.use("/api/*", cors());
// List recent webhooks
app.get("/api/webhooks", async (c) => {
const limit = parseInt(c.req.query("limit") ?? "50");
const source = c.req.query("source");
let stmt;
if (source) {
stmt = c.env.DB.prepare(
"SELECT id, source, event_type, received_at FROM webhooks WHERE source = ? ORDER BY received_at DESC LIMIT ?"
).bind(source, limit);
} else {
stmt = c.env.DB.prepare(
"SELECT id, source, event_type, received_at FROM webhooks ORDER BY received_at DESC LIMIT ?"
).bind(limit);
}
const { results } = await stmt.all();
return c.json(results);
});
// Get a single webhook with payload
app.get("/api/webhooks/:id", async (c) => {
const id = c.req.param("id");
const webhook = await c.env.DB.prepare(
"SELECT * FROM webhooks WHERE id = ?"
)
.bind(id)
.first();
if (!webhook) return c.json({ error: "Not found" }, 404);
return c.json(webhook);
});
// List forwarding rules
app.get("/api/rules", async (c) => {
const { results } = await c.env.DB.prepare(
"SELECT id, source, target_url, active, healthy, last_health_check FROM forwarding_rules ORDER BY source"
).all();
return c.json(results);
});
// Delivery log for a webhook
app.get("/api/deliveries", async (c) => {
const webhookId = c.req.query("webhook_id");
if (!webhookId) return c.json({ error: "webhook_id required" }, 400);
const { results } = await c.env.DB.prepare(
"SELECT * FROM delivery_log WHERE webhook_id = ? ORDER BY delivered_at DESC"
)
.bind(webhookId)
.all();
return c.json(results);
});
export default app;
Build the React Dashboard
A minimal dashboard that fetches webhooks and displays them. Polling refreshes every 10 seconds.
// src/App.tsx
import { useEffect, useState } from "react";
interface Webhook {
id: number;
source: string;
event_type: string;
received_at: string;
}
function App() {
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchWebhooks() {
try {
const res = await fetch("/api/webhooks?limit=20");
const data = await res.json();
setWebhooks(data);
} catch (err) {
console.error("Failed to fetch webhooks:", err);
} finally {
setLoading(false);
}
}
fetchWebhooks();
const interval = setInterval(fetchWebhooks, 10_000);
return () => clearInterval(interval);
}, []);
if (loading) return <p>Loading...</p>;
return (
<div style={{ maxWidth: 800, margin: "0 auto", padding: 20 }}>
<h1>Webhook Hub</h1>
<p>{webhooks.length} recent webhooks</p>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={thStyle}>ID</th>
<th style={thStyle}>Source</th>
<th style={thStyle}>Event</th>
<th style={thStyle}>Received</th>
</tr>
</thead>
<tbody>
{webhooks.map((wh) => (
<tr key={wh.id}>
<td style={tdStyle}>{wh.id}</td>
<td style={tdStyle}>{wh.source}</td>
<td style={tdStyle}>{wh.event_type}</td>
<td style={tdStyle}>{wh.received_at}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
const thStyle: React.CSSProperties = {
textAlign: "left",
borderBottom: "2px solid #ccc",
padding: "8px 4px",
};
const tdStyle: React.CSSProperties = {
borderBottom: "1px solid #eee",
padding: "8px 4px",
};
export default App;
This is intentionally simple. A production dashboard would add filtering, detail views, and delivery status. The point is to demonstrate the full-stack pattern.
Run Locally
npx vite dev
The Cloudflare Vite plugin:
- Builds the React app with HMR
- Runs the Hono API in a local
workerdruntime - Routes
/api/*to the Worker, everything else to the SPA
Open http://localhost:5173 to see the dashboard. Send a test webhook and watch it appear:
curl -X POST http://localhost:5173/api/webhook/github \
-H "X-Event-Type: push" \
-d '{"ref": "refs/heads/main", "commits": []}'
Gotcha: If you add the webhook ingestion routes to this Worker, they share the same D1 binding. In production you might run the ingestion Worker separately and share the D1 database across Workers. Both approaches work - Cloudflare lets multiple Workers bind to the same D1 database.
Deploy
npx vite build && npx wrangler deploy
Vite builds the React app to dist/client/. Wrangler uploads the static assets and the Worker together. The result is a single Workers deployment:
- Static files served from Cloudflare’s edge (cached globally)
- API routes handled by the Worker with D1 access
- SPA routing handled by
not_found_handling: "single-page-application"
Upgrading to Real-Time
This dashboard uses polling (fetch every 10 seconds). For real-time webhook delivery updates, you can upgrade to WebSockets using Durable Objects. The Durable Objects deep-dive covers:
- Creating a DO that holds WebSocket connections
- Broadcasting delivery events to connected dashboards
- Hibernation to avoid idle resource usage
The API and React structure stays the same; you add a WebSocket connection alongside the polling.
What You Built
A complete full-stack Cloudflare app: React SPA for the frontend, Hono API for the backend, D1 for data, all deployed as a single Worker with Static Assets. The Webhook Hub now has a dashboard.
Tip: For a comparison of all 10 frameworks you can deploy to Cloudflare Workers (React Router v7, Astro, SvelteKit, and more), see the Cloudflare Frameworks topic.
Next: The Durable Objects deep-dive adds real-time WebSocket updates to this dashboard.