Skip to content

backpine/tanstack-trpc-on-cloudflare

Repository files navigation

TanStack + tRPC on Cloudflare Workers

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.


Table of Contents

  1. Cloudflare + Vite Setup
  2. Project Structure
  3. tRPC Setup
  4. TanStack Query Integration
  5. Effect Integration (Optional)
  6. Development
  7. Deployment

Cloudflare + Vite Setup

This project uses the @cloudflare/vite-plugin to seamlessly integrate Cloudflare Workers with Vite's development experience.

How It Works

The Cloudflare Vite plugin enables a unified development and deployment workflow:

  1. Development: Vite serves your React app with HMR, while the plugin runs your Worker code locally using Wrangler's runtime
  2. Build: Vite builds the frontend to dist/, and Wrangler bundles your Worker
  3. Deploy: A single wrangler deploy pushes both the static assets and Worker to Cloudflare's edge

vite.config.js

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
  ],
});

wrangler.jsonc

{
  "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
  }
}

Key Configuration: run_worker_first

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/.


Project Structure

├── 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 Setup

tRPC provides end-to-end type safety between your frontend and backend without code generation.

Backend: Worker Setup

1. Context (worker/trpc/context.ts)

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>>;

2. tRPC Instance (worker/trpc/trpc-instance.ts)

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;

3. Router (worker/trpc/router.ts)

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;

4. Procedures (worker/trpc/routers/example.ts)

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 };
    }),
});

5. Hono Integration (worker/api/entry.ts)

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,
      }),
  });
});

6. Worker Entry (worker/index.ts)

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;

TanStack Query Integration

tRPC integrates with TanStack Query using @trpc/tanstack-react-query. This project uses the context-free setup with createTRPCOptionsProxy.

Client Setup (src/utils/trpc.ts)

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,
});

App Entry (src/main.tsx)

import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "./utils/trpc";

root.render(
  <QueryClientProvider client={queryClient}>
    <RouterProvider router={router} />
  </QueryClientProvider>
);

Usage in Components

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!
}

Query Patterns

// 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,
});

Effect Integration (Optional)

For complex backend operations, this project includes an optional Effect-TS setup. Effect provides typed errors, dependency injection, and composable business logic.

Why Effect?

  • 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

Structure

worker/effect/
├── index.ts              # Barrel exports
├── errors.ts             # Typed error classes
├── runtime.ts            # Edge runtime + tRPC runners
└── programs/
    └── example.ts        # Business logic as Effects

Error Types (worker/effect/errors.ts)

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;

Edge Runtime (worker/effect/runtime.ts)

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);

Programs (worker/effect/programs/example.ts)

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 };
  });

Using in tRPC Routers

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))
    ),
});

Expanding with Services

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,
);

Development

# 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 serve

The dev server runs at http://localhost:3000 with:

  • Hot Module Replacement for React
  • Live reload for Worker code
  • Full Cloudflare Workers runtime locally

Deployment

# Build and deploy to Cloudflare
pnpm deploy

This command:

  1. Builds the frontend with Vite
  2. Bundles the Worker with Wrangler
  3. Deploys both to Cloudflare's edge network

Environment Variables & Secrets

Use Wrangler to manage secrets:

# Set a secret
wrangler secret put MY_SECRET

# List secrets
wrangler secret list

Configure 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);
});

License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published