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:
pnpm dlx create-turbo@latest --example https://github.com/bimsina/vite-react-cf-starterAfter you’ve cloned the project, the backend code should look something like this:
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:
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
- Install the packages
pnpm add @trpc/server@next zodWe also install zod, which validates the user input.
- Define the router
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
export const router = t.router;export const publicProcedure = t.procedure;- Initialize the router instance
import { router } from "./trpc";
export const appRouter = router({ // ...});
export type AppRouter = typeof appRouter;- Add a query
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;- Validate the input with zod
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;- Serve the API
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
- Install the package
pnpm add @trpc/client@next- Create a client instance
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;- Consume the API
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:
The complete code is at bimsina/cf-worker-trpc.
If you have questions or feedback, reach out.