TanStack Start as a tRPC Alternative: Why You Don't Need tRPC Anymore

By Aziz Ali
tanstack-starttrpctypescriptsaasserver-functions

If you've built anything with the T3 Stack, you know the tRPC ritual: define a router, create a procedure, wrap everything in trpc.procedure.input(z.object({...})), and wire up the client. It works, but it's ceremony. Every API call is a ceremony.

Then I started using TanStack Start — and I realized I didn't need tRPC at all.

What tRPC Actually Solves (And What It Doesn't)

tRPC is brilliant at one thing: end-to-end type safety without a separate schema language. You write your server function, and TypeScript just knows the shape on the client. No codegen, no OpenAPI, no guessing.

The problem is that tRPC was built to solve the type-safety problem that exists when you have a separate API layer. REST or GraphQL APIs live in their own world — you need tRPC to bridge the type gap.

TanStack Start eliminates that gap entirely by collapsing the client-server boundary. Your server code and client code live in the same router. There's no bridge to build.

TanStack Start Server Functions vs tRPC Procedures

Here's what a tRPC procedure looks like in a T3 Stack project:

// server/trpc/routers/subscription.ts
export const subscriptionRouter = createTRPCRouter({
  getStatus: protectedProcedure
    .input(z.object({ userId: z.string() }))
    .query(async ({ ctx, input }) => {
      return await db.query.subscriptions.findFirst({
        where: eq(subscriptions.userId, input.userId),
      });
    }),
});

// Then on the client...
const { data } = api.subscription.getStatus.useQuery({ userId: session.user.id });

And here's the equivalent in TanStack Start using server functions:

// app/functions/subscription.ts
import { createServerFn } from "@tanstack/start";

export const getSubscriptionStatus = createServerFn({ method: "GET" })
  .validator((data: { userId: string }) => data)
  .handler(async ({ data }) => {
    return await db.query.subscriptions.findFirst({
      where: eq(subscriptions.userId, data.userId),
    });
  });

// On the client...
const status = await getSubscriptionStatus({ data: { userId: session.user.id } });

Both are fully type-safe. But the TanStack approach requires zero router configuration, zero adapter setup, zero createTRPCContext. You just write a function.

The Architecture Difference

flowchart LR
    subgraph T3["T3 Stack (tRPC)"]
        A[React Component] -->|useQuery| B[tRPC Client]
        B -->|HTTP| C[tRPC Router]
        C --> D[Procedure Handler]
        D --> E[(Database)]
    end

    subgraph TS["TanStack Start"]
        F[React Component] -->|direct call| G[Server Function]
        G --> H[(Database)]
    end

With tRPC, there are 4 hops between your component and your database. With TanStack Start server functions, there are 2. Less abstraction, fewer places for things to go wrong, and a much simpler mental model.

When tRPC Still Makes Sense

I want to be fair here: tRPC is the right tool when you have a public API or when you need to expose your backend to multiple clients (mobile app, third-party integrations, etc.).

If you're building an API-first product — where the "API" is something external developers consume — tRPC or a proper REST/GraphQL setup is the right call.

But for most SaaS products? You have one web client, owned entirely by you. There's no need for a formalized API layer. You just need your frontend to talk to your database securely. Server functions handle that beautifully.

What About Mutations?

Same story. In tRPC you'd define a mutation procedure. In TanStack Start, you use a POST server function:

export const updateUserPlan = createServerFn({ method: "POST" })
  .validator((data: { userId: string; plan: "free" | "pro" }) => data)
  .handler(async ({ data }) => {
    await db
      .update(users)
      .set({ plan: data.plan })
      .where(eq(users.id, data.userId));

    return { success: true };
  });

Call it from your component with await updateUserPlan({ data: { userId, plan: "pro" } }). TypeScript knows the input and output shapes. Done.

If you want to see this wired with Stripe webhooks triggering plan updates, I wrote a full breakdown in Stripe Webhooks in TanStack Start.

The T3 Stack Comparison

If you're evaluating TanStack Start as a full T3 Stack alternative, the mapping is pretty direct:

T3 Stack Component TanStack Start Equivalent
Next.js TanStack Start
tRPC Server Functions (createServerFn)
Prisma Drizzle ORM
NextAuth Better-Auth
Node.js Bun

The net result: fewer dependencies, less boilerplate, no per-seat auth pricing, and a runtime that's meaningfully faster. I wrote more about this in The Best create-t3-app Alternative in 2026 if you want the full comparison.

Real-World Complexity: What Happens at Scale?

A fair concern: does the server function model hold up when your app grows? In my experience, yes — with one caveat. You need to be disciplined about co-location. Put server functions next to the routes that use them, not in a giant api/ directory that becomes its own spaghetti layer.

I organize mine like this:

app/
  routes/
    dashboard/
      index.tsx          ← the route
      _actions.ts        ← server functions for this route
    settings/
      index.tsx
      _actions.ts

This keeps things discoverable. Anyone reading dashboard/index.tsx can immediately find the server functions it uses. No tracing through tRPC routers to figure out what api.dashboard.* does.

FAQ

Does TanStack Start server functions work with TypeScript strict mode? Yes — server functions are fully typed end-to-end. Input validators, return types, and error shapes are all TypeScript-native. No extra type generation step needed.

Can I still use tRPC with TanStack Start if I want to? Technically yes, but it's mostly redundant. You'd be adding the tRPC abstraction layer on top of a framework that already solves the type-safety problem natively. It adds complexity without meaningful benefit for single-client SaaS apps.

What about real-time features — do server functions support that? Server functions are request-response only. For real-time needs (WebSockets, SSE), you'd add a separate layer. TanStack Start doesn't block this, but it doesn't abstract it either. This is actually the same limitation tRPC has for subscriptions.

Is TanStack Start production-ready? Yes. I covered what production readiness actually means in detail in TanStack Start Production-Ready SaaS. Short answer: it's stable, has active Tanner Linsley maintenance, and is what BetterStarter is built on.

How hard is it to migrate from tRPC to TanStack Start server functions? Procedure-by-procedure, it's a mechanical refactor. The input/output shapes stay the same. The biggest lift is replacing the tRPC React client hooks with direct async calls or TanStack Query. A weekend project for most apps.


If you want a TanStack Start project that already has server functions, auth, Stripe, and database wired up the right way, BetterStarter ships all of this for $99 one-time. No tRPC, no ceremony — just a codebase you can actually understand and ship from.