42.7 Sharing Types Between Frontend and Backend in a Monorepo
Let’s be honest: you’ve probably duct-taped an interface from your backend into your frontend before. You copied it, pasted it, held your breath, and prayed nothing changed. It’s the digital equivalent of trying to match paint colors by memory. The moment you update the backend, the frontend shatters into a million type errors, and you’re left picking up the pieces.
We’re done with that. In a monorepo, your frontend and backend aren’t just neighbors; they’re roommates sharing a single brain. They should be consuming the exact same type definitions from a single source of truth. No more drift, no more lies.
The Monorepo Structure is Your Foundation
First, get your house in order. A typical setup for a Next.js + tRPC monorepo looks something like this:
my-monorepo/
├── apps/
│ ├── next-app/ # Your Next.js frontend
│ └── express-app/ # Your backend (or another Next.js app)
├── packages/
│ ├── api/ # Your tRPC router definition
│ └── db/ # Your database client & types
└── package.json # Root package.json (optional)
The magic happens in that packages/api directory. This isn’t an application; it’s a package that contains your tRPC router and, crucially, all the types your frontend and backend will use. Both apps/next-app and apps/express-app will import from packages/api.
Defining Your tRPC Router in a Shared Package
Inside packages/api, you’ll define your tRPC router. This is the contract. This is the law.
// packages/api/src/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
// 1. Initialize tRPC context - we'll get to this in a moment
const t = initTRPC.create();
// 2. Define your procedures
export const appRouter = t.router({
getUser: t.procedure
.input(z.object({ userId: z.string() })) // Input validation with Zod
.query(async ({ input }) => {
// This is where you'd fetch from your database.
// The hypothetical return type is { id: string; name: string }
return { id: input.userId, name: 'Brendan Eich' };
}),
createUser: t.procedure
.input(z.object({ name: z.string().min(1) }))
.mutation(async ({ input }) => {
// ... create user logic
return { id: '1', name: input.name };
}),
});
// 3. Export these TYPES for your frontend to consume
export type AppRouter = typeof appRouter;
The key here is the last line. We’re exporting the type of appRouter. This is a pure type, it doesn’t exist in your compiled JavaScript. It’s a blueprint that TypeScript can use to understand the entire structure of your API.
The Frontend: Importing the Holy TypeScript Grail
Now, over in your Next.js app (apps/next-app), you import that type. First, make sure your apps/next-app/package.json has a dependency on your shared package: "@my-monorepo/api": "workspace:*".
Then, you set up your tRPC client.
// apps/next-app/utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@my-monorepo/api'; // <- This is the magic!
export const trpc = createTRPCReact<AppRouter>();
Boom. That’s it. Your trpc client now has full, autocomplete-driven knowledge of every procedure, its inputs, and its return types.
// Inside a React component
function MyComponent() {
// `getUser` is fully typed. It KNOWs the input requires a `userId: string`
const { data } = trpc.getUser.useQuery({ userId: '123' });
const createUser = trpc.createUser.useMutation();
if (data) {
// `data` is typed as { id: string; name: string } | undefined
console.log(data.name); // Perfectly safe, autocomplete works
}
const handleClick = () => {
// This will throw a type error because 'email' is not a valid field.
// You catch this at compile time, not runtime.
createUser.mutate({ email: 'test@example.com' });
// This is correct. The error is caught before you even save the file.
createUser.mutate({ name: 'Grace Hopper' });
};
return <button onClick={handleClick}>Create User</button>;
}
Why This is a Game-Changer
This setup eliminates an entire class of bugs. API endpoints are no longer “stringly typed.” You’re not calling POST /api/creat-user and hoping you spelled it right. You’re calling trpc.createUser.mutate() and the TypeScript compiler is your relentless, nitpicking code reviewer, ensuring you pass the right data every single time. Refactoring becomes a joy. Change a procedure’s input in packages/api and your entire monorepo will light up with type errors showing you exactly where you need to update your client code. It’s like having a super-powered find-and-replace that understands semantics.
The Rough Edges and Pitfalls
It’s not all rainbows. The main pitfall is circular dependencies. Your api package should be the lowest-level package. It can depend on db, but your db package should not depend on api. If you find yourself needing a type from api in your db logic, that type probably lives in the wrong package. Move it down to db or a new shared-types package.
Also, be mindful of your build process. You need to build packages/api before you build apps/next-app so the latest types are available. Tools like Turborepo or Nx handle this choreography for you with caching and task pipelines. Don’t try to do it manually; you’ll hate your life.
This approach requires a bit more upfront structure, but the payoff is a level of type safety that feels less like programming and more like witchcraft. And the best part? It’s not magic—it’s just a really smart way to use the tools you already have.