5 min read

Using tRPC with Cloudflare Workers in a monorepo

Setting up tRPC with Cloudflare Workers in a monorepo for typesafe APIs without schema generation.

This post sets up tRPC with Cloudflare Workers in a monorepo.

What is tRPC?

tRPC lets you build typesafe APIs without generating a schema and keeping it in sync between server and client.

You call your API as if it were a local function, and TypeScript checks the call end to end.

What is a monorepo?

A monorepo is a single repository that holds multiple projects, such as a frontend and a backend. It makes it easier to share code and manage dependencies between them.


There are many monorepo tools. This setup uses Turborepo.

To get started, clone vite-react-cf-starter, or bootstrap a new project from it:

Terminal window
pnpm dlx create-turbo@latest --example https://github.com/bimsina/vite-react-cf-starter

After you’ve cloned the project, the backend code should look something like this:

apps/server/src/index.ts
import { sharedString } from '@repo/utils';
import { corsifyResponse } from './cors';
export default {
async fetch(request, env, ctx): Promise<Response> {
return corsifyResponse(
new Response(
JSON.stringify({
message: 'Hello from Cloudflare Worker!',
shared: sharedString,
}),
{
headers: {
'Content-Type': 'application/json',
},
},
),
request,
env,
);
},
} satisfies ExportedHandler<Env>;

Calling the backend from the frontend looks like this:

apps/client/src/App.tsx
fetch("http://localhost:8787/")
.then((res) => res.json())
.then((data) => {
// handle the response
const { message, shared } = data;
console.log(message, shared);
});

This works, but it has two problems:

The API is not typesafe

  • What if the backend changes the response shape?

  • What if the frontend parses the response incorrectly?

The API is not documented

  • How does the frontend developer know what input the endpoint expects?

tRPC fixes both. The call is typesafe end to end, and the input and output types document themselves.


Set up tRPC on the server

  1. Install the packages
apps/server
pnpm add @trpc/server@next zod

We also install zod, which validates the user input.

  1. Define the router
apps/server/src/trpc.ts
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
  1. Initialize the router instance
apps/server/src/router.ts
import { router } from "./trpc";
export const appRouter = router({
// ...
});
export type AppRouter = typeof appRouter;
  1. Add a query
apps/server/src/router.ts
import { publicProcedure, router } from './trpc';
const appRouter = router({
greetUser: publicProcedure.query(() => {
return {
message: 'Hello, user!',
};
}),
});
// Export type router type signature,
// NOT the router itself.
export type AppRouter = typeof appRouter;
  1. Validate the input with zod
apps/server/src/router.ts
import { z } from 'zod';
import { publicProcedure, router } from './trpc';
const appRouter = router({
greetUser: publicProcedure
.input(
z.object({
name: z.string(),
}),
)
.query((opts) => {
const { input } = opts;
return {
message: `Hello, ${input.name}!`,
};
}),
});
// Export type router type signature,
// NOT the router itself.
export type AppRouter = typeof appRouter;
  1. Serve the API
apps/server/src/1index.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "./router";
import { sharedString } from "@repo/utils";
import { corsifyResponse } from "./cors";
export default {
async fetch(request, env, ctx): Promise<Response> {
if (request.url.includes("trpc")) {
return corsifyResponse(
await fetchRequestHandler({
endpoint: "/trpc",
req: request,
router: appRouter,
}),
request,
env,
);
}
return corsifyResponse(
new Response(
JSON.stringify({
message: "Hello from Cloudflare Worker!",
shared: sharedString,
}),
{
headers: {
"Content-Type": "application/json",
},
},
),
request,
env,
);
},
} satisfies ExportedHandler<Env>;

The Worker now serves the tRPC routes under /trpc.

Set up tRPC on the client

  1. Install the package
apps/client
pnpm add @trpc/client@next
  1. Create a client instance
apps/client/src/trpc.ts
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "../../server/src/router";
const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: "http://localhost:3000",
}),
],
});
export default trpc;
  1. Consume the API
apps/client/src/App.tsx
import { useEffect, useState } from "react";
import "./App.css";
import { sharedString } from "@repo/utils";
import trpc from "./trpc";
function App() {
const [count, setCount] = useState(0);
const [message, setMessage] = useState("");
useEffect(() => {
fetch("http://localhost:8787/")
.then((res) => res.json())
.then((data) => {
setMessage(JSON.stringify(data));
});
const user = trpc.greetUser.query({
name: "John",
});
user.then((data) => {
setMessage(data.message);
});
}, []);
return (
<div>
<h1>Vite + React + CF Workers</h1>
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<h3>The response from CF worker is: </h3>
<p>{message}</p>
<p>Shared string : {sharedString}</p>
</div>
);
}
export default App;

That’s it. The client now calls the server with end-to-end typesafety, and no generated schema to keep in sync.


This is just the setup. To go further, start with these:

  1. Defining Procedures
  2. TanStack Query integration
  3. Video tutorials

The complete code is at bimsina/cf-worker-trpc.

If you have questions or feedback, reach out.

Share