01/22/26

tRPC vs Encore.ts in 2026

Comparing two approaches to end-to-end type safety

9 Min Read

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.

Quick Comparison

AspecttRPCEncore.ts
What It IsType-safe API layerFull backend framework
Type SafetyClient-server type inferenceTypes for APIs, services, and infrastructure
Works WithAny HTTP framework (Express, Fastify, Next.js)Standalone framework
InfrastructureNone (bring your own)Built-in (databases, Pub/Sub, cron)
Primary Use CaseFull-stack TypeScript apps (frontend + backend)Backend systems and microservices
TransportHTTP, WebSocketsHTTP (with automatic service communication)
Client GenerationAutomatic (TypeScript inference)Automatic (generated clients)
Local DevelopmentStandard toolingAutomatic infrastructure provisioning
AI Agent CompatibilityManual infrastructure setupBuilt-in infrastructure awareness
Best ForNext.js apps, monoreposDistributed systems, cloud deployments

Understanding the Approaches

tRPC

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

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.

Type Safety Approaches

tRPC

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.ts

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.

Integration with Existing Code

tRPC

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.ts

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.

Frontend Integration

tRPC

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.ts

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.

Infrastructure

tRPC

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.ts

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.

Service-to-Service Communication

tRPC

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.

Encore.ts

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.

Encore service catalog

Verdict: Encore is designed for service-to-service communication. tRPC is designed for client-server communication.

Observability

tRPC

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.

Encore.ts

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.

Encore distributed tracing

Verdict: Encore provides built-in observability. tRPC requires external tools.

When to Choose tRPC

tRPC makes sense when:

  • You're building a full-stack TypeScript app with frontend and backend in a monorepo
  • You're using Next.js and want seamless API integration
  • You want to add type-safe APIs to an existing project without major changes
  • Your primary concern is frontend-backend type safety rather than backend infrastructure
  • You prefer to choose your own infrastructure (databases, hosting, etc.)

When to Choose Encore.ts

Encore.ts makes sense when:

  • You're building backend systems with multiple services
  • You want infrastructure automation for databases, Pub/Sub, and cron jobs
  • You need type-safe service-to-service communication within your backend
  • You want built-in observability without configuration
  • You're deploying to AWS or GCP and want automated infrastructure provisioning
  • You want infrastructure-aware code that AI agents can generate and deploy (with guardrails)

Can You Use Both?

Yes. tRPC and Encore solve different problems:

  • Use Encore for your backend services with databases, Pub/Sub, and service communication
  • Use tRPC (or Encore's generated clients) for your frontend-to-backend API layer

For example, you might have:

  • Encore services handling your backend logic
  • A Next.js frontend using Encore's generated TypeScript client

Or:

  • tRPC for your Next.js API routes
  • External services (whether Encore or something else) for background processing

Getting Started

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.

Ready to build your next backend?

Encore is the Open Source framework for building robust type-safe distributed systems with declarative infrastructure.