DEV Community

Mohammad Garmabi
Mohammad Garmabi

Posted on

tRPC + TanStack Router: Build Fully Type-Safe Data Fetching with tanstack-fetch

An end-to-end guide to combining tRPC, TanStack Query, TanStack Router, and tanstack-fetch in a modern TypeScript application.

Modern TypeScript applications need more than a convenient way to fetch data. They need reliable API contracts, predictable caching, route-aware data loading, and a clean separation between HTTP transport and server state.

That is where this stack becomes interesting:

  • tRPC provides end-to-end type safety between your server and client.
  • tanstack-fetch handles HTTP transport, request configuration, and reusable networking behavior.
  • TanStack Query manages caching, query state, and background refetching.
  • TanStack Router coordinates navigation and route-level data loading.

Instead of implementing these concerns independently, we can combine them into a single, consistent data-fetching architecture.

In this tutorial, we'll build a practical example using a post.list procedure.

1. Understand how the pieces fit together

Before writing code, it's important to understand that these libraries solve different problems.

Library Responsibility
tRPC Type-safe API procedures and client-server contracts
tanstack-fetch HTTP transport, interceptors, plugins, and request behavior
TanStack Query Caching, query lifecycle, and server-state synchronization
TanStack Router Navigation, route context, and loader execution

The request flow looks like this:

TanStack Router → TanStack Query → tRPC client → tanstack-fetch → tRPC server

The key architectural decision is that TanStack Router doesn't need to know how HTTP requests work. It uses query options through its context, while tanstack-fetch handles the transport layer underneath tRPC.

This separation keeps routing, server state, and networking concerns independent.

2. Install the dependencies

For a React application using TanStack Router and TanStack Query, install the following packages:

npm install tanstack-fetch @trpc/client \
  @trpc/tanstack-react-query \
  @tanstack/react-query \
  @tanstack/react-router
Enter fullscreen mode Exit fullscreen mode

You'll also need your application's tRPC server and its exported AppRouter type.

The examples below assume that your server exposes a procedure at post.list through /api/trpc.

3. Create a shared HTTP client with tanstack-fetch

Start by configuring the HTTP transport.

Create src/lib/trpc.ts:

import { createFetch } from 'tanstack-fetch'
import { createTRPCFetchClient } from 'tanstack-fetch/trpc'
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query'
import { QueryClient } from '@tanstack/react-query'
import type { AppRouter } from '../server/router'

export const api = createFetch({
  plugins: ['trace'],
})

export const queryClient = new QueryClient()

export const trpc = createTRPCOptionsProxy<AppRouter>({
  client: createTRPCFetchClient<AppRouter>({
    url: '/api/trpc',
    client: api,
  }),
  queryClient,
})
Enter fullscreen mode Exit fullscreen mode

Let's break down what's happening.

Why use createFetch?

createFetch gives us a reusable HTTP client that can centralize networking behavior instead of scattering it throughout our application.

Depending on your configuration, this is where you can organize concerns such as authentication, error handling, plugins, and request instrumentation.

For example, if multiple API procedures need the same transport-level behavior, you can configure that behavior once rather than reimplementing it for individual queries.

Why use createTRPCFetchClient?

tRPC already provides the procedure-based API and type information. createTRPCFetchClient connects that API to the HTTP client created with tanstack-fetch.

The generic AppRouter connects the client to your server's procedure definitions, allowing TypeScript to infer the available procedures and their input and output types.

Why use createTRPCOptionsProxy?

This helper from @trpc/tanstack-react-query exposes tRPC procedures as TanStack Query-compatible options.

For example:

trpc.post.list.queryOptions()
Enter fullscreen mode Exit fullscreen mode

Instead of manually maintaining query keys and query functions for every tRPC procedure, you can use the options generated from the typed client.

4. Integrate TanStack Router with query context

Now that the API client exists, we can connect it to the router.

Create or update src/router.tsx:

import { createRouter } from '@tanstack/react-router'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient, trpc } from './lib/trpc'
import { routeTree } from './routeTree.gen'

export const router = createRouter({
  routeTree,

  context: {
    trpc,
    queryClient,
  },

  Wrap: ({ children }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  ),
})
Enter fullscreen mode Exit fullscreen mode

The important part is the router context:

context: {
  trpc,
  queryClient,
}
Enter fullscreen mode Exit fullscreen mode

This makes the typed tRPC options and QueryClient available to route loaders.

A loader can now prepare data before the route renders, without importing the API client directly into every route module.

This also makes route dependencies explicit and gives us a consistent pattern to reuse across the application.

5. Fetch data in a route loader

Let's create a posts page.

import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'

export const Route = createFileRoute('/posts')({
  loader: ({ context }) =>
    context.queryClient.ensureQueryData(
      context.trpc.post.list.queryOptions(),
    ),

  component: PostsPage,
})

function PostsPage() {
  const { trpc } = Route.useRouteContext()

  const { data, isPending, isError } = useQuery(
    trpc.post.list.queryOptions(),
  )

  if (isPending) {
    return <p>Loading posts...</p>
  }

  if (isError) {
    return <p>Failed to load posts.</p>
  }

  return (
    <ul>
      {data.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

There are two important operations here.

ensureQueryData in the loader

loader: ({ context }) =>
  context.queryClient.ensureQueryData(
    context.trpc.post.list.queryOptions(),
  )
Enter fullscreen mode Exit fullscreen mode

The loader asks TanStack Query to ensure that the query has data available before the route proceeds.

If suitable cached data already exists, it can be reused. Otherwise, the query can fetch the data.

This is especially useful when the page depends on server data to provide a meaningful initial render.

useQuery in the component

const { data } = useQuery(
  trpc.post.list.queryOptions(),
)
Enter fullscreen mode Exit fullscreen mode

The component subscribes to the same query managed by TanStack Query.

Because the loader and component use the same query options, they share the query's cache identity. You don't need a separate query key or a second manually implemented fetch function.

The result is a consistent data flow from navigation to rendering.

6. What happens when the user navigates to /posts?

Here's the lifecycle:

  1. The user navigates to /posts.
  2. TanStack Router runs the route loader.
  3. The loader calls ensureQueryData.
  4. TanStack Query checks its cache and fetches if necessary.
  5. The tRPC client invokes the post.list procedure.
  6. tanstack-fetch handles the HTTP transport.
  7. The server returns the procedure result.
  8. TanStack Query stores the result in its cache.
  9. The route component reads that query through useQuery.

This architecture avoids manually coordinating the router's loading state with a separate data-fetching implementation.

It also allows TanStack Query to manage subsequent updates, refetching, and cache reuse.

One important detail: ensureQueryData doesn't necessarily force a fresh request when cached data exists. If your route needs to revalidate stale data, choose an appropriate freshness policy or use ensureQueryData's revalidateIfStale option where suitable.

7. Why not call tRPC directly inside every component?

You certainly can call tRPC procedures directly. The question is how you want to manage server state.

Consider this approach:

const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)

useEffect(() => {
  trpcClient.post.list
    .query()
    .then(setPosts)
    .finally(() => setLoading(false))
}, [])
Enter fullscreen mode Exit fullscreen mode

This is a simplified illustration of manually managing a request. In a larger application, you would also need to account for errors, cancellation, caching, duplicate requests, and component lifecycle behavior.

With TanStack Query, those concerns are handled by a dedicated server-state layer. With tRPC's Query integration, the procedures remain fully typed.

And with tanstack-fetch, transport behavior can remain separate from the component and query logic.

Each tool has a clearly defined responsibility.

8. Where tanstack-fetch adds value

tRPC already provides a powerful client-server contract, so why introduce another HTTP client?

The answer is transport-level consistency.

In an application with multiple API integrations, you may want shared networking behavior for authentication, errors, request instrumentation, and other HTTP concerns.

tanstack-fetch provides a configurable transport layer and a tRPC integration, allowing you to reuse its HTTP behavior while keeping tRPC's procedure types.

For example, the same transport client can be configured with plugins:

import { createFetch } from 'tanstack-fetch'

export const api = createFetch({
  plugins: ['trace'],
})
Enter fullscreen mode Exit fullscreen mode

The example uses a configured plugin identifier; the actual plugin must be available in your installed version and project setup.

The objective isn't to add another abstraction for its own sake. It's to keep networking behavior in one place when that centralization provides real value.

If your application only has a tiny tRPC integration and no need for shared HTTP behavior, the simpler native tRPC setup may be sufficient.

9. What about authentication and server-side rendering?

For a browser-only application, a relative URL such as /api/trpc is usually appropriate.

For server-side rendering, the request may need an absolute URL, and authentication cookies or headers may need to be forwarded to the server.

A simplified URL helper looks like this:

const getTrpcUrl = () =>
  typeof window === 'undefined'
    ? `${process.env.SSR_ORIGIN ?? 'http://localhost:3000'}/api/trpc`
    : '/api/trpc'
Enter fullscreen mode Exit fullscreen mode

However, this is only part of a correct SSR setup.

For server rendering, configure the HTTP client with the appropriate SSR behavior, forward only the request-specific credentials that are required, and create request-scoped clients and query caches where necessary. Avoid sharing user-specific authentication state through a global server-side singleton.

For TanStack Start, the official integration recipe demonstrates the combination of tRPC, TanStack Query, router context, and SSR-aware transport configuration.

See the full guide: tRPC + tanstack-fetch.

10. The resulting architecture

The benefit of this approach is not simply fewer lines of code. It is a clearer separation of responsibilities:

  • TanStack Router determines when route data should be prepared.
  • TanStack Query manages cached server state and its lifecycle.
  • tRPC provides typed procedure calls between the client and server.
  • tanstack-fetch centralizes configurable HTTP transport behavior.

You can evolve each layer without turning every route into a collection of networking, caching, and loading-state decisions.

Final thoughts

TanStack Router, TanStack Query, and tRPC already make a strong foundation for type-safe applications. Adding tanstack-fetch is most useful when you also want a reusable HTTP transport layer with shared networking behavior.

The result is an architecture where route loaders prepare data, query options define how server state is accessed, tRPC maintains end-to-end type safety, and the HTTP layer remains independent of UI components.

The key principle: keep routing, server state, API contracts, and HTTP transport separate — but integrate them through explicit, typed interfaces.

Explore the integration guide: https://mohamadgarmabi.github.io/tanstack-fetch/guide/trpc#trpc

By Mohammad Garmabi — creator of tanstack-fetch, an independent HTTP client for the TanStack ecosystem. Not an official TanStack package.

Top comments (0)