Full-stack React app with SSR, Durable Objects, and SQLite. Built on React Router 7 and Cloudflare Workers.
Use React Router 7 in framework mode (successor to Remix): data reads in route loader(), mutations in action(), and forms/fetchers for server-driven updates. Prefer this over SPA-style client fetching patterns by default.
bun dev # Start dev server
bun run test # Run tests
bun run build # Build for production
bun run deploy # Deploy to Cloudflareapp/
routes/ # React Router routes (loaders, actions, components)
schemas/ # Zod schemas (shared between routes and DOs)
workers/
app.ts # Worker entry point (exports DOs)
example-do.ts # Example Durable Object with SQLite
tests/ # Vitest tests for Durable Objects
wrangler.jsonc # Cloudflare config (bindings, migrations)
vitest.config.ts # Vitest config for Workers pool
export async function loader({ context }: Route.LoaderArgs) {
// Access any binding via context.cloudflare.env
const id = context.cloudflare.env.EXAMPLE_DO.idFromName("global");
const stub = context.cloudflare.env.EXAMPLE_DO.get(id);
// Call RPC methods directly (not fetch!)
const data = await stub.listContacts();
return { data };
}The starter includes a DATA_PROXY service binding.
- Local dev: binding points to
LocalDataProxyService(workers/data-proxy.ts), which forwards toDATA_PROXY_URLwhen set - camelAI deploy: platform rewrites
DATA_PROXYto its internal service binding
export async function loader({ context }: Route.LoaderArgs) {
const result = await context.cloudflare.env.DATA_PROXY.mysqlQuery({
mode: "read",
host: "db.example.com",
user: "user",
password: "pass",
database: "analytics",
query: "SELECT * FROM orders WHERE customer_id = ?",
params: [123],
});
if (!result.ok) {
throw new Error(result.error.message);
}
return { rows: result.data.recordset ?? [] };
}export class MyDO extends DurableObject<Env> {
private sql: SqlStorage;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.sql = ctx.storage.sql;
// Create tables (idempotent)
this.sql.exec(`CREATE TABLE IF NOT EXISTS items (...)`);
}
// RPC method - callable from routes
async listItems() {
return this.sql.exec("SELECT * FROM items").toArray();
}
}Put schemas in app/schemas/ and import in both routes and DOs:
// app/schemas/contact.ts
export const CreateContactSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
// In route action:
const result = CreateContactSchema.safeParse(formData);
// In DO: use the same types
async createContact(input: CreateContactInput) { ... }// Shows during initial hydration (SSR → client)
export function HydrateFallback() {
return <div>Loading...</div>;
}bun run test # Run tests with vitestTests use @cloudflare/vitest-pool-workers for isolated DO testing:
import { describe, it, expect } from "vitest";
import { env } from "cloudflare:test";
describe("MyDO", () => {
it("creates and retrieves items", async () => {
// Get a DO stub from env bindings
const id = env.MY_DO.idFromName("test");
const stub = env.MY_DO.get(id);
// Call RPC methods directly
await stub.createItem({ name: "Test" });
const items = await stub.listItems();
expect(items).toHaveLength(1);
});
});See workers/tests/example-do.test.ts for a complete example.
Known limitation: Tests expecting DO methods to throw exceptions may cause "Failed to pop isolated storage stack frame" errors. Test error handling in route actions instead.
- Create the class in
workers/my-do.ts - Export from
workers/app.ts:export { MyDO } from "./my-do" - Add binding to
wrangler.jsonc: - Add migration (increment tag):
"migrations": [ { "tag": "v1", "new_sqlite_classes": ["ExampleDO"] }, { "tag": "v2", "new_sqlite_classes": ["MyDO"] } ]
postinstallrunswrangler typesautomatically; re-runbun run cf-typegenafter binding changes to updateEnvtypes
Use bunx to run the shadcn CLI:
bunx --bun shadcn@latest add button card inputComponents install to app/components/ui/.