A full-stack TypeScript application running on Cloudflare Workers with:
- Frontend: React 19, TanStack Router, TanStack Query
- Backend: Cloudflare Workers, Hono, tRPC
- Styling: Tailwind CSS v4, shadcn/ui
- Optional: Effect-TS for robust backend logic
Both frontend and backend deploy to the same Cloudflare Worker, served from the same domain.
- Cloudflare + Vite Setup
- Project Structure
- tRPC Setup
- TanStack Query Integration
- Effect Integration (Optional)
- Development
- Deployment
This project uses the @cloudflare/vite-plugin to seamlessly integrate Cloudflare Workers with Vite's development experience.
The Cloudflare Vite plugin enables a unified development and deployment workflow:
- Development: Vite serves your React app with HMR, while the plugin runs your Worker code locally using Wrangler's runtime
- Build: Vite builds the frontend to
dist/, and Wrangler bundles your Worker - Deploy: A single
wrangler deploypushes both the static assets and Worker to Cloudflare's edge
import { defineConfig } from "vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import viteReact from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import tsConfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [
tsConfigPaths(), // Path aliases
tanstackRouter({ target: "react", autoCodeSplitting: true }), // File-based routing
viteReact(), // React Fast Refresh
tailwindcss(), // Tailwind CSS v4
cloudflare(), // Cloudflare Workers
],
});The assets.run_worker_first array defines which routes are handled by your Worker before falling back to static assets:
/trpc/*- All tRPC API calls/api/*- Any additional REST endpoints
All other routes serve the SPA from dist/.
├── src/ # Frontend (React)
│ ├── routes/ # TanStack Router file-based routes
│ │ └── index.tsx # Home page
│ ├── components/ui/ # shadcn/ui components
│ ├── utils/
│ │ └── trpc.ts # tRPC client setup
│ ├── lib/
│ │ └── utils.ts # Utility functions (cn)
│ └── main.tsx # App entry point
│
├── worker/ # Backend (Cloudflare Worker)
│ ├── index.ts # Worker entry point
│ ├── api/
│ │ └── entry.ts # Hono app with tRPC handler
│ ├── trpc/
│ │ ├── context.ts # tRPC context (req, env, workerCtx)
│ │ ├── trpc-instance.ts # tRPC initialization
│ │ ├── router.ts # Root router
│ │ └── routers/
│ │ └── example.ts # Example procedures
│ └── effect/ # Effect-TS (optional)
│ ├── index.ts # Barrel exports
│ ├── errors.ts # Typed error classes
│ ├── runtime.ts # Edge runtime + runners
│ └── programs/
│ └── example.ts # Business logic as Effects
│
├── vite.config.js
├── wrangler.jsonc
└── package.json
tRPC provides end-to-end type safety between your frontend and backend without code generation.
The context is created for each request and provides access to Cloudflare-specific objects:
export async function createContext({
req,
env,
workerCtx,
}: {
req: Request;
env: Env; // Cloudflare bindings (D1, KV, etc.)
workerCtx: ExecutionContext; // For waitUntil, passThroughOnException
}) {
return { req, env, workerCtx };
}
export type Context = Awaited<ReturnType<typeof createContext>>;Initialize tRPC with your context type:
import { initTRPC } from "@trpc/server";
import type { Context } from "./context";
export const t = initTRPC.context<Context>().create();
export const publicProcedure = t.procedure;Combine your routers:
import { t } from "./trpc-instance";
import { exampleRouter } from "./routers/example";
export const appRouter = t.router({
example: exampleRouter,
});
export type AppRouter = typeof appRouter;Define your API procedures:
import { z } from "zod";
import { t, publicProcedure } from "../trpc-instance";
export const exampleRouter = t.router({
hello: publicProcedure
.input(z.object({ name: z.string().optional() }))
.query(({ input }) => ({
greeting: `Hello, ${input.name ?? "World"}!`
})),
getItem: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
if (input.id === "404") {
throw new TRPCError({ code: "NOT_FOUND", message: "Item not found" });
}
return { id: input.id, name: `Item ${input.id}`, price: 100 };
}),
});Mount tRPC on Hono:
import { Hono } from "hono";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/worker/trpc/router";
import { createContext } from "@/worker/trpc/context";
export const App = new Hono<{ Bindings: Env }>();
App.all("/trpc/*", (c) => {
return fetchRequestHandler({
endpoint: "/trpc",
req: c.req.raw,
router: appRouter,
createContext: () =>
createContext({
req: c.req.raw,
env: c.env,
workerCtx: c.executionCtx,
}),
});
});Export the Hono app as the Worker:
import { App } from "@/worker/api/entry";
const worker: ExportedHandler<Env> = {
async fetch(request, env, ctx) {
return await App.fetch(request, env, ctx);
},
};
export default worker;tRPC integrates with TanStack Query using @trpc/tanstack-react-query. This project uses the context-free setup with createTRPCOptionsProxy.
import { QueryClient } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import type { AppRouter } from "../../worker/trpc/router";
export const queryClient = new QueryClient();
const trpcClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: "/trpc", // Same domain, Worker handles this route
}),
],
});
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
});import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "./utils/trpc";
root.render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);The trpc proxy generates query options compatible with TanStack Query hooks:
import { useQuery } from "@tanstack/react-query";
import { trpc } from "../utils/trpc";
function MyComponent() {
// Type-safe query with full autocomplete
const { data, isLoading, error } = useQuery(
trpc.example.hello.queryOptions({ name: "World" })
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{data.greeting}</div>; // data is typed!
}// Basic query
const { data } = useQuery(trpc.example.hello.queryOptions({ name: "World" }));
// Query with error handling
const { data, error } = useQuery(trpc.example.getItem.queryOptions({ id: "123" }));
// Mutation
const mutation = useMutation(trpc.example.createItem.mutationOptions());
mutation.mutate({ name: "New Item", price: 50 });
// Prefetching in loaders
export const Route = createFileRoute("/items/$id")({
loader: ({ params }) =>
queryClient.ensureQueryData(trpc.example.getItem.queryOptions({ id: params.id })),
component: ItemPage,
});For complex backend operations, this project includes an optional Effect-TS setup. Effect provides typed errors, dependency injection, and composable business logic.
- Typed Errors: Know exactly what can fail and handle it explicitly
- Dependency Injection: Services (database, config) are injected via layers
- Testability: Pure functions that describe computations, easy to mock
- Composability: Combine effects with
pipe,gen, and other combinators
worker/effect/
├── index.ts # Barrel exports
├── errors.ts # Typed error classes
├── runtime.ts # Edge runtime + tRPC runners
└── programs/
└── example.ts # Business logic as Effects
Define typed errors with a discriminated union:
export class NotFoundError {
readonly _tag = "NotFoundError";
constructor(readonly message: string) {}
}
export class ValidationError {
readonly _tag = "ValidationError";
constructor(readonly message: string) {}
}
export class UnauthorizedError {
readonly _tag = "UnauthorizedError";
constructor(readonly message: string) {}
}
export class InternalError {
readonly _tag = "InternalError";
constructor(readonly message: string) {}
}
export type AppError =
| NotFoundError
| ValidationError
| UnauthorizedError
| InternalError;A custom Effect runtime optimized for Cloudflare Workers:
import { Effect, Layer, ManagedRuntime } from "effect";
import { TRPCError } from "@trpc/server";
import type { AppError } from "./errors";
// Base layer - expand with services (DatabaseService, ConfigService, etc.)
const BaseLayer = Layer.empty;
// Edge runtime for Cloudflare Workers
export const EdgeRuntime = ManagedRuntime.make(BaseLayer);
// Map Effect errors to tRPC error codes
const mapErrorToTRPCCode = (error: AppError): TRPCError["code"] => {
switch (error._tag) {
case "NotFoundError": return "NOT_FOUND";
case "ValidationError": return "BAD_REQUEST";
case "UnauthorizedError": return "UNAUTHORIZED";
case "InternalError": return "INTERNAL_SERVER_ERROR";
}
};
// Run an Effect and convert errors to TRPCError
export const runTRPC = <A>(
effect: Effect.Effect<A, AppError, never>
): Promise<A> =>
EdgeRuntime.runPromise(
effect.pipe(
Effect.catchAll((error) =>
Effect.fail(
new TRPCError({
code: mapErrorToTRPCCode(error),
message: error.message,
})
)
)
)
);
// Run an infallible Effect
export const runTRPCSync = <A>(
effect: Effect.Effect<A, never, never>
): Promise<A> => EdgeRuntime.runPromise(effect);Define your business logic as pure Effects:
import { Effect } from "effect";
import { NotFoundError } from "../errors";
export const greet = (name: string) =>
Effect.succeed({ greeting: `Hello, ${name}!` });
export const getItem = (id: string) =>
Effect.gen(function* () {
if (id === "404") {
return yield* Effect.fail(new NotFoundError(`Item ${id} not found`));
}
return { id, name: `Item ${id}`, price: 100 };
});Wire Effect programs to tRPC procedures:
import { z } from "zod";
import { t, publicProcedure } from "../trpc-instance";
import { runTRPC, runTRPCSync, ExamplePrograms } from "../../effect";
export const exampleRouter = t.router({
// Standard tRPC (no Effect)
hello: publicProcedure
.input(z.object({ name: z.string().optional() }))
.query(({ input }) => ({
greeting: `Hello, ${input.name ?? "World"}!`
})),
// With Effect
effectHello: publicProcedure
.input(z.object({ name: z.string().optional() }))
.query(({ input }) =>
runTRPCSync(ExamplePrograms.greet(input.name ?? "World"))
),
effectGetItem: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) =>
runTRPC(ExamplePrograms.getItem(input.id))
),
});Add services to your runtime layer:
// worker/effect/services/database.ts
import { Effect, Context, Layer } from "effect";
class DatabaseService extends Context.Tag("DatabaseService")<
DatabaseService,
{
readonly query: <T>(sql: string) => Effect.Effect<T[], Error>;
}
>() {}
const makeDatabaseService = (db: D1Database) =>
Layer.succeed(DatabaseService, {
query: <T>(sql: string) =>
Effect.tryPromise({
try: () => db.prepare(sql).all<T>().then((r) => r.results),
catch: (e) => new Error(`Query failed: ${e}`),
}),
});
// worker/effect/runtime.ts
const BaseLayer = Layer.mergeAll(
makeDatabaseService(env.DB), // Pass D1 binding
// ConfigService.Live,
// AuthService.Live,
);# Install dependencies
pnpm install
# Start development server (Vite + Wrangler)
pnpm dev
# Generate Cloudflare types
pnpm cf-typegen
# Build for production
pnpm build
# Preview production build locally
pnpm serveThe dev server runs at http://localhost:3000 with:
- Hot Module Replacement for React
- Live reload for Worker code
- Full Cloudflare Workers runtime locally
# Build and deploy to Cloudflare
pnpm deployThis command:
- Builds the frontend with Vite
- Bundles the Worker with Wrangler
- Deploys both to Cloudflare's edge network
Use Wrangler to manage secrets:
# Set a secret
wrangler secret put MY_SECRET
# List secrets
wrangler secret listConfigure bindings in wrangler.jsonc:
{
"d1_databases": [
{ "binding": "DB", "database_name": "my-database", "database_id": "xxx" }
],
"kv_namespaces": [
{ "binding": "KV", "id": "xxx" }
]
}Access bindings in your Worker via env:
App.get("/api/data", async (c) => {
const result = await c.env.DB.prepare("SELECT * FROM users").all();
return c.json(result);
});MIT
{ "name": "tanstack-start-on-cloudflare", "main": "./worker/index.ts", // Worker entry point "compatibility_date": "2025-10-08", "compatibility_flags": ["nodejs_compat"], // Node.js compatibility "assets": { "run_worker_first": ["/trpc/*", "/api/*"], // These routes hit the Worker "directory": "./dist/", // Static assets directory "not_found_handling": "single-page-application" // SPA fallback } }