tRPC and Encore.ts both promise end-to-end type safety, but they're fundamentally different tools. tRPC is an API layer that eliminates the need for schemas by inferring types directly from your backend code. Encore.ts is a full backend framework where infrastructure is defined in code and types flow through your entire system.
The choice isn't always either/or. They solve different problems, and this comparison helps you understand when each approach fits.
| Aspect | tRPC | Encore.ts |
|---|---|---|
| What It Is | Type-safe API layer | Full backend framework |
| Type Safety | Client-server type inference | Types for APIs, services, and infrastructure |
| Works With | Any HTTP framework (Express, Fastify, Next.js) | Standalone framework |
| Infrastructure | None (bring your own) | Built-in (databases, Pub/Sub, cron) |
| Primary Use Case | Full-stack TypeScript apps (frontend + backend) | Backend systems and microservices |
| Transport | HTTP, WebSockets | HTTP (with automatic service communication) |
| Client Generation | Automatic (TypeScript inference) | Automatic (generated clients) |
| Local Development | Standard tooling | Automatic infrastructure provisioning |
| AI Agent Compatibility | Manual infrastructure setup | Built-in infrastructure awareness |
| Best For | Next.js apps, monorepos | Distributed systems, cloud deployments |
tRPC removes the API boundary between frontend and backend in TypeScript projects. Instead of defining schemas or generating clients, you call backend procedures directly with full type inference:
// server/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return { id: input.id, name: 'John', email: '[email protected]' };
}),
createUser: t.procedure
.input(z.object({
email: z.string().email(),
name: z.string(),
}))
.mutation(async ({ input }) => {
return { id: '1', ...input };
}),
});
export type AppRouter = typeof appRouter;
// client/index.ts
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router';
const trpc = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: 'http://localhost:3000/api/trpc' })],
});
// Full type safety - IDE autocomplete, type errors if mismatched
const user = await trpc.getUser.query({ id: '1' });
console.log(user.name); // TypeScript knows this is string
Types flow directly from your backend to your frontend without code generation or schema files.
Encore.ts is a backend framework where types define your APIs and infrastructure:
// users/api.ts
import { api } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";
const db = new SQLDatabase("users", { migrations: "./migrations" });
interface User {
id: string;
name: string;
email: string;
}
interface GetUserRequest {
id: string;
}
export const getUser = api(
{ method: "GET", path: "/users/:id", expose: true },
async ({ id }: GetUserRequest): Promise<User> => {
const user = await db.queryRow<User>`
SELECT id, name, email FROM users WHERE id = ${id}
`;
if (!user) throw new Error("User not found");
return user;
}
);
// From another service
import { users } from "~encore/clients";
// Type-safe call
const user = await users.getUser({ id: "1" });
Encore generates type-safe clients for service-to-service communication and can also generate clients for external consumers.
Key Difference: tRPC connects your frontend directly to your backend in a monorepo. Encore connects services within a backend system and provides infrastructure automation.
tRPC's type inference is its core feature:
// Define a procedure
const appRouter = t.router({
greeting: t.procedure
.input(z.object({ name: z.string() }))
.query(({ input }) => {
return `Hello, ${input.name}!`;
}),
});
// Client automatically knows:
// - greeting.query takes { name: string }
// - greeting.query returns string
const message = await trpc.greeting.query({ name: 'World' });
The magic happens through TypeScript's type inference. Runtime type information doesn't cross the network, so it's purely a development-time feature. You use Zod for runtime validation.
Encore validates types at runtime based on your TypeScript interfaces:
interface GreetingRequest {
name: string;
}
export const greeting = api(
{ method: "GET", path: "/greeting", expose: true },
async ({ name }: GreetingRequest): Promise<string> => {
return `Hello, ${name}!`;
}
);
Invalid requests are rejected before your handler runs. The same types work for service-to-service calls:
import { greetings } from "~encore/clients";
// Type-safe, validated at both ends
const message = await greetings.greeting({ name: "World" });
Verdict: tRPC provides seamless type inference for frontend-backend communication. Encore provides type safety across services with runtime validation. Both eliminate the need for manual API schemas.
tRPC integrates with existing HTTP frameworks:
// With Express
import express from 'express';
import { createExpressMiddleware } from '@trpc/server/adapters/express';
import { appRouter } from './router';
const app = express();
app.use('/api/trpc', createExpressMiddleware({ router: appRouter }));
app.listen(3000);
// With Next.js (App Router)
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/router';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
});
export { handler as GET, handler as POST };
tRPC fits into your existing stack. It's an API layer, not a framework replacement.
Encore is a standalone framework:
// users/encore.service.ts
import { Service } from "encore.dev/service";
export default new Service("users");
// users/api.ts
import { api } from "encore.dev/api";
export const getUser = api(/* ... */);
You build your entire backend with Encore. If you have existing services, you can call them via HTTP or gradually migrate.
Verdict: tRPC is easier to adopt incrementally. Encore requires more commitment but provides more features.
tRPC shines in full-stack TypeScript apps, especially with React:
// With React Query integration
import { trpc } from './utils/trpc';
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading } = trpc.getUser.useQuery({ id: userId });
if (isLoading) return <div>Loading...</div>;
return (
<div>
<h1>{user?.name}</h1>
<p>{user?.email}</p>
</div>
);
}
The React Query integration provides caching, refetching, and optimistic updates with full type safety.
Encore can generate clients for external consumers:
# Generate TypeScript client
encore gen client my-app --lang=typescript --output=./client
import Client from './client';
const client = new Client({ baseURL: 'https://my-app.encr.app' });
const user = await client.users.getUser({ id: '1' });
For React integration, you'd use the generated client with React Query or similar:
import { useQuery } from '@tanstack/react-query';
import { client } from './api';
function UserProfile({ userId }: { userId: string }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => client.users.getUser({ id: userId }),
});
return <div>{user?.name}</div>;
}
Verdict: tRPC has tighter React integration out of the box. Encore requires an extra step to generate clients, but works with any frontend framework.
tRPC doesn't include infrastructure, so you bring your own:
// With Prisma
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return prisma.user.findUnique({ where: { id: input.id } });
}),
});
You set up databases, manage connections, and handle infrastructure separately.
Encore provides infrastructure primitives:
import { api } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";
import { Topic, Subscription } from "encore.dev/pubsub";
import { CronJob } from "encore.dev/cron";
const db = new SQLDatabase("app", { migrations: "./migrations" });
const userCreated = new Topic<{ userId: string }>("user-created", {
deliveryGuarantee: "at-least-once",
});
const _ = new CronJob("cleanup", {
title: "Daily cleanup",
every: "24h",
endpoint: cleanupEndpoint,
});
Local development automatically provisions databases and supporting infrastructure.
Verdict: tRPC is infrastructure-agnostic. Encore provides integrated infrastructure with automatic provisioning.
tRPC is primarily designed for client-server communication, not service-to-service:
// Calling another service requires manual setup
const otherService = createTRPCClient<OtherServiceRouter>({
links: [httpBatchLink({ url: process.env.OTHER_SERVICE_URL })],
});
const result = await otherService.someMethod.query({ /* ... */ });
You manage service URLs and handle the setup yourself.
Service communication is a core feature:
// users/api.ts
export const getUser = api(/* ... */);
// orders/api.ts
import { users } from "~encore/clients";
export const createOrder = api(
{ method: "POST", path: "/orders", expose: true },
async (req: CreateOrderRequest) => {
// Type-safe, automatic service discovery
const user = await users.getUser({ id: req.userId });
return { orderId: '1', user };
}
);
Services discover each other automatically. Calls are traced across service boundaries. The service catalog visualizes your architecture.

Verdict: Encore is designed for service-to-service communication. tRPC is designed for client-server communication.
tRPC doesn't include observability:
// Manual logging
const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
console.log('Getting user:', input.id);
// Add your own tracing, metrics, etc.
return { /* ... */ };
}),
});
You integrate logging, tracing, and metrics yourself.
import { api } from "encore.dev/api";
import log from "encore.dev/log";
export const getUser = api(
{ method: "GET", path: "/users/:id", expose: true },
async ({ id }: { id: string }) => {
log.info("fetching user", { userId: id });
// Database queries and service calls are traced automatically
return { /* ... */ };
}
);
Distributed tracing works automatically across services and database calls.

Verdict: Encore provides built-in observability. tRPC requires external tools.
tRPC makes sense when:
Encore.ts makes sense when:
Yes. tRPC and Encore solve different problems:
For example, you might have:
Or:
Try both:
See also: Best TypeScript Backend Frameworks for more options, or NestJS vs Encore.ts if you're considering a more traditional framework.
Have questions about choosing a framework? Join our Discord community where developers discuss architecture decisions daily.