I’ve built the same little CRUD app a dozen times to try out stacks. This time I wanted one thing above all else: a single source of truth for the API that the server and the client both compile against. Change a field, and both ends refuse to build until they agree.
The combination that delivered it:
- Turborepo + Bun workspaces — the monorepo glue
- TanStack Router (Vite + React 19) — file-based, type-safe routing on the frontend
- NestJS 11 — the backend framework
- oRPC — the contract-first RPC layer (
@orpc/neston the server,@orpc/tanstack-queryon the client) - Better Auth — sessions, wired into Nest via
@thallesp/nestjs-better-auth - Drizzle ORM + Postgres — the data layer
The payoff is real, but two integration points took some figuring out. Those are the parts worth reading even if you never touch this exact stack, so I’ll spend most of the post there.
Repository
You can view the complete code for this project in the repository here: https://github.com/mguay22/orpc-betterauth-turbo
The Shape of the Repo
orpc-betterauth-turbo/
├── apps/
│ ├── web/ # Vite + React + TanStack Router
│ └── api/ # NestJS + oRPC + Better Auth + Drizzle
├── packages/
│ └── contract/ # the shared oRPC contract — the heart of the thing
├── turbo.json
└── docker-compose.yml
Everything orbits packages/contract. It has no framework dependencies — just oRPC’s contract builder and Zod — so both apps can import it freely.
One Contract to Rule Them All
A contract is a description of your procedures: their HTTP route, input schema, and output schema. Nothing else.
// packages/contract/src/index.ts
import { oc, populateContractRouterPaths } from "@orpc/contract";
import { z } from "zod";
export const TodoSchema = z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
createdAt: z.string(), // ISO-8601
});
export type Todo = z.infer<typeof TodoSchema>;
const listTodos = oc
.route({ method: "GET", path: "/todos" })
.output(z.array(TodoSchema));
const createTodo = oc
.route({ method: "POST", path: "/todos" })
.input(z.object({ title: z.string().min(1).max(200) }))
.output(TodoSchema);
// ...toggle, delete
export const contract = populateContractRouterPaths({
todo: {
list: listTodos,
create: createTodo,
toggle: toggleTodo,
delete: deleteTodo,
},
});
That’s it. The server is now obligated to implement these exact shapes, and the client gets a fully typed SDK for free. No code generation step, no OpenAPI YAML to babysit — just TypeScript inference across a workspace package.
Implementing the Contract in NestJS
@orpc/nest lets a normal Nest controller implement contract procedures. Each method binds to one procedure and validates input/output against the Zod schemas automatically.
// apps/api/src/todo/todo.controller.ts
@AllowAnonymous()
@Controller()
export class TodoController {
constructor(private readonly todos: TodoService) {}
@Implement(contract.todo.list)
list() {
return implement(contract.todo.list)
.use(requireAuth)
.handler(({ context }) => this.todos.list(context.user.id));
}
@Implement(contract.todo.create)
create() {
return implement(contract.todo.create)
.use(requireAuth)
.handler(({ input, context }) =>
this.todos.create(context.user.id, input.title),
);
}
// ...toggle, delete
}
Two things to notice here, both of which I’ll explain: the @AllowAnonymous() on the class, and .use(requireAuth) injecting context.user. They’re the crux of making oRPC and Better Auth coexist.
Gotcha #1: Two Libraries, One Disabled Body Parser
Better Auth needs the raw request body to verify signed payloads. So does @orpc/nest, which parses bodies itself per procedure. NestJS, by default, eagerly parses every request body with express.json() before your handlers run — which breaks both.
The fix is to disable Nest’s global body parser at bootstrap:
// apps/api/src/main.ts
const app = await NestFactory.create<NestExpressApplication>(
AppModule,
{
bodyParser: false,
},
);
app.enableCors({
origin: process.env.WEB_URL ?? "http://localhost:5173",
credentials: true,
});
This is the happy accident that makes the stack work: both libraries want the same thing.
@thallesp/nestjs-better-auth re-applies JSON and URL-encoded parsing for the non-auth routes that need it, while oRPC handles its own request parsing.
If you forget bodyParser: false, auth fails with cryptic signature errors and oRPC POST requests hang.
If you forget credentials: true on CORS, authentication succeeds but every API call returns 401 because the session cookie never crosses origins.
Gotcha #2: Who Owns Authorization — Nest or oRPC?
@thallesp/nestjs-better-auth registers a global Nest AuthGuard.
By default it protects every route, and you opt specific routes out with @AllowAnonymous().
That’s lovely for traditional REST controllers:
@Controller()
export class AppController {
@AllowAnonymous()
@Get("/")
health() {
return { ok: true, service: "api" };
}
@Get("/me")
me(@Session() session: UserSession) {
return session;
}
}
But oRPC procedures are a different paradigm.
I wanted authorization to live inside the typed oRPC pipeline, so that protected procedures receive a typed context.user instead of reaching into the raw request.
The clean solution is to mark the entire oRPC controller @AllowAnonymous() and enforce authentication with an oRPC middleware instead.
// apps/api/src/orpc/auth.middleware.ts
import { os } from "@orpc/server";
import { ORPCError } from "@orpc/nest";
import { fromNodeHeaders } from "better-auth/node";
import type { Request } from "express";
import { auth } from "../auth.js";
export const requireAuth = os
.$context<{ request: Request }>()
.middleware(async ({ context, next }) => {
const result = await auth.api.getSession({
headers: fromNodeHeaders(context.request.headers),
});
if (!result) {
throw new ORPCError("UNAUTHORIZED", {
message: "Authentication required",
});
}
return next({
context: {
user: result.user,
session: result.session,
},
});
});
For the middleware to read the request, the request must be available in the oRPC context.
You wire that up once in your module using Nest’s REQUEST provider:
// apps/api/src/app.module.ts
declare module "@orpc/nest" {
interface ORPCGlobalContext {
request: Request;
}
}
@Module({
imports: [
AuthModule.forRoot({ auth }),
ORPCModule.forRootAsync({
useFactory: (request: Request) => ({
context: { request },
interceptors: [
onError((e) => console.error(e)),
],
}),
inject: [REQUEST],
}),
],
controllers: [AppController, TodoController],
providers: [TodoService],
})
export class AppModule {}
Now .use(requireAuth) does two jobs:
- Rejects anonymous requests with a typed
UNAUTHORIZEDerror. - Injects a typed
context.userinto downstream handlers.
Authorization becomes part of the contract pipeline rather than a separate concern bolted onto the side.
The mental model that finally made this click:
Better Auth’s Nest guard governs your REST routes.
An oRPC middleware governs your oRPC procedures.
Both read the same session. They just enforce it at different layers.
The Client: The Contract as a Typed SDK
On the frontend, the same contract becomes a client.
The only non-obvious piece is ensuring session cookies ride along on cross-origin requests:
// apps/web/src/lib/orpc.ts
const link = new OpenAPILink(contract, {
url: import.meta.env.VITE_API_URL ?? "http://localhost:3000",
fetch: (request, init) =>
fetch(request, {
...init,
credentials: "include",
}),
});
export const client: JsonifiedClient<
ContractRouterClient<typeof contract>
> = createORPCClient(link);
export const orpc = createTanstackQueryUtils(client);
createTanstackQueryUtils() turns every procedure into TanStack Query factories.
Components never need to manually define query keys or fetchers:
const todosQuery = useQuery(
orpc.todo.list.queryOptions(),
);
const createMutation = useMutation(
orpc.todo.create.mutationOptions({
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: orpc.todo.list.key(),
}),
}),
);
todosQuery.data is inferred as Todo[].
createMutation.mutate() requires { title: string }.
Change the contract and both sides immediately reflect the new types.
Protecting Routes (and a Sign-Out Gotcha)
TanStack Router protects authenticated sections with a beforeLoad hook:
export const Route = createFileRoute("/_authenticated")({
beforeLoad: async ({ location }) => {
const { data } = await getSession();
if (!data) {
throw redirect({
to: "/login",
search: {
redirect: location.href,
},
});
}
return { session: data };
},
});
One subtle issue bit me here.
beforeLoad() only runs during navigation or page load — not reactively when authentication state changes.
After signOut(), the navbar updated because it used useSession(), but the protected page remained visible until refresh.
The fix:
async function handleSignOut() {
await signOut();
await navigate({ to: "/" });
await router.invalidate();
}
Navigating away first prevents a brief flash through /login before the router re-evaluates its guards.
Does It Actually Work?
A quick walkthrough tells the whole story:
- Anonymous routes stay open.
- Unauthenticated oRPC calls are rejected by middleware.
- Authenticated calls flow through oRPC, Drizzle, Postgres, and back with full type safety.
Was It Worth the Wiring?
Yes — and the reason is boring in the best possible way.
After the initial setup, adding a feature becomes:
- Define the procedure in the contract.
- Implement it in the Nest controller.
- Consume it on the client.
- The compiler guards every step.
- No hand-written API types.
- No generated SDKs.
- No OpenAPI specs drifting out of sync.
- Just one contract shared across the entire stack.
The only genuinely tricky parts were:
- Disabling Nest’s body parser so Better Auth and oRPC can coexist.
- Deciding whether Nest or oRPC owns authorization.
Once those are solved, the rest of the stack largely disappears — which is exactly what you want from infrastructure.