# Set up with TanStack Start

> 

This guide shows how to use tRPC with [TanStack Start](https://tanstack.com/start): route loaders start queries, and components read them through [handles](https://trpc.io/docs/client/tanstack-react-query/handles.md).

> [!NOTE] Info
> We have prefixed the handle APIs with `unstable_` as they're new, but you're safe to use them! [Read more](https://trpc.io/docs/faq.md#unstable).


tRPC adds no router integration of its own. Data crosses from the server to the browser through TanStack's [SSR query integration](https://tanstack.com/router/latest/docs/integrations/query), with your tRPC data transformer.

The [SSE chat example](https://github.com/trpc/trpc/tree/main/examples/tanstack-start-sse-chat) is a complete TanStack Start app that uses handles in its loaders.

## 1. Install deps

```sh
npm install @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query @tanstack/react-router-ssr-query superjson zod
```

## 2. Create a query client factory

Use your tRPC transformer to serialize query data, and set a `staleTime` above 0 so the browser doesn't refetch right after hydration:

```ts title="src/lib/query-client.ts"
import { QueryClient } from '@tanstack/react-query';
import superjson from 'superjson';

export function createQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { staleTime: 30 * 1000 },
      dehydrate: { serializeData: superjson.serialize },
      hydrate: { deserializeData: superjson.deserialize },
    },
  });
}
```

## 3. Create an isomorphic tRPC client

During server rendering, call procedures in the same process with `localLink`. In the browser, call them over HTTP:

```ts title="src/lib/trpc.ts"
import { createIsomorphicFn } from '@tanstack/react-start';
import { getRequest } from '@tanstack/react-start/server';
import {
  createTRPCClient,
  httpBatchStreamLink,
  localLink,
  type TRPCLink,
} from '@trpc/client';
import { createTRPCContext, type TRPCOptionsProxy } from '@trpc/tanstack-react-query';
import superjson from 'superjson';
import { createContext } from '~/server/context';
import { appRouter, type AppRouter } from '~/server/routers/_app';

export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
export type TRPC = TRPCOptionsProxy<AppRouter>;

const getLinks = createIsomorphicFn()
  .server((): TRPCLink<AppRouter>[] => [
    localLink({
      router: appRouter,
      createContext: () => createContext({ req: getRequest() }),
      transformer: superjson,
    }),
  ])
  .client((): TRPCLink<AppRouter>[] => [
    httpBatchStreamLink({ url: '/api/trpc', transformer: superjson }),
  ]);

export const makeTRPCClient = () =>
  createTRPCClient<AppRouter>({ links: getLinks() });
```

The browser's link calls `/api/trpc`. Serve it from a [server route](https://tanstack.com/start/latest/docs/framework/react/guide/server-routes) with the [fetch adapter](https://trpc.io/docs/server/adapters/fetch.md):

```ts title="src/routes/api/trpc.$.ts"
import { createFileRoute } from '@tanstack/react-router';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { createContext } from '~/server/context';
import { appRouter } from '~/server/routers/_app';

const handler = ({ request }: { request: Request }) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req: request,
    router: appRouter,
    createContext,
  });

export const Route = createFileRoute('/api/trpc/$')({
  server: { handlers: { GET: handler, POST: handler } },
});
```

## 4. Put the proxy in the router context

Create one proxy per router with `createTRPCOptionsProxy`, and pass it to loaders through the router context:

```tsx title="src/router.tsx"
import { createRouter } from '@tanstack/react-router';
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query';
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
import { createQueryClient } from '~/lib/query-client';
import { makeTRPCClient, TRPCProvider } from '~/lib/trpc';
import type { AppRouter } from '~/server/routers/_app';
import { routeTree } from './routeTree.gen';

export function getRouter() {
  const queryClient = createQueryClient();
  const trpcClient = makeTRPCClient();
  const trpc = createTRPCOptionsProxy<AppRouter>({
    client: trpcClient,
    queryClient,
  });

  const router = createRouter({
    routeTree,
    context: { queryClient, trpc },
    defaultPreload: 'intent',
    // Let the query cache decide what's fresh: loaders always run, and fresh data is reused
    defaultPreloadStaleTime: 0,
    Wrap: (props) => (
      <TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
        {props.children}
      </TRPCProvider>
    ),
  });
  setupRouterSsrQueryIntegration({ router, queryClient });
  return router;
}
```

Type the root route's context with `createRootRouteWithContext<{ queryClient: QueryClient; trpc: TRPCOptionsProxy<AppRouter> }>()`.

## 5. Load handles in loaders

A loader lists its queries as entries, loads them with `loadHandles`, and returns the handles as loader data. For each entry, choose whether the loader waits for it. [Loading handles](https://trpc.io/docs/client/tanstack-react-query/handles.md#starting-a-query) compares the options. Every input should come from the route's params or search params: see [Start every query from the URL](#url-as-input).

- On the first visit, the loader runs on the server, and the data is sent with the page.
- On later navigations, the loader runs in the browser against the tab's cache. Fresh data is reused; stale data renders at once and refetches.



### Without waiting: prefetch entries {#loader-prefetch}

The load resolves at once, and the route renders before the data arrives:

```tsx title="src/routes/posts.$id.comments.tsx"
import { createFileRoute } from '@tanstack/react-router';
import { loadHandles, prefetchInfiniteEntry } from '@trpc/tanstack-react-query/unstable-handles';
import { Suspense } from 'react';
import { Comments } from '~/components/comments';

export const Route = createFileRoute('/posts/$id/comments')({
  loader: async ({ context: { trpc }, params }) => {
    const [handles] = await loadHandles(trpc, {
      comments: prefetchInfiniteEntry(
        trpc.post.comments,
        { postId: params.id, limit: 20 },
        { getNextPageParam: (last) => last.nextCursor },
      ),
    });
    return handles;
  },
  component: CommentsPage,
});

function CommentsPage() {
  const loaderData = Route.useLoaderData();
  return (
    <Suspense fallback={<p>Loading comments…</p>}>
      <Comments comments={loaderData.comments} />
    </Suspense>
  );
}
```

- **Wrap the reader in `Suspense`.** `Comments` suspends until the data arrives. Without your own boundary, the suspension reaches the nearest boundary above it: the route's `pendingComponent`, or a parent route's. That can blank much more of the page than you want.
- The load never rejects for these queries. If a request fails, `Comments` throws, and the route's `errorComponent` (or the router's default) shows the error.
- **Pass `getNextPageParam` to infinite queries.** The loader runs again on client navigations, and if the cached pages are stale, it refetches every page the user loaded. That [needs `getNextPageParam`](https://trpc.io/docs/client/tanstack-react-query/handles.md#infinite-queries) from page two on.

### Waiting: ensure entries {#loader-ensure}

The loader waits, so the route renders with the data in place:

```tsx title="src/routes/posts.$id.tsx"
import { createFileRoute, notFound } from '@tanstack/react-router';
import {
  ensureEntry,
  isTRPCEntryError,
  loadHandles,
} from '@trpc/tanstack-react-query/unstable-handles';
import { Post } from '~/components/post';

export const Route = createFileRoute('/posts/$id')({
  loader: async ({ context: { trpc }, params }) => {
    const [handles] = await loadHandles(trpc, {
      post: ensureEntry(trpc.post.byId, { id: params.id }),
    }).catch((cause) => {
      if (isTRPCEntryError(cause, trpc.post.byId) && cause.code === 'NOT_FOUND') {
        throw notFound();
      }
      throw cause;
    });
    return handles;
  },
  component: PostPage,
});

function PostPage() {
  const loaderData = Route.useLoaderData();
  return <Post post={loaderData.post} />;
}
```

- The data is in the cache before the route renders, so `Post` shows the post at once. If the cache loses the post later, `Post` suspends and shows the nearest `Suspense` fallback, or the route's `pendingComponent`: see [When the cache loses the data](https://trpc.io/docs/client/tanstack-react-query/handles.md#cache-loses-data).
- The navigation waits for the loader. On a slow request, the router shows the route's `pendingComponent`, if it has one.
- The loader can act on errors, as here with `notFound()`.
- The handle carries no data. To use the data in the loader, for example for the route's `head`, read it from the load's `data`: `const [handles, data] = await loadHandles(trpc, …)`, and return what you need. See [Using the data in a loader](https://trpc.io/docs/client/tanstack-react-query/handles.md#loader-data).

To wait for some queries and stream others in the same loader, list them all in one load: see [Waiting for some, streaming others](https://trpc.io/docs/client/tanstack-react-query/handles.md#combining-both).

## 6. Read handles in components

Components read handles with `fromHandle` and `useTRPC()`. They're the same components you would write for the [Next.js App Router](https://trpc.io/docs/client/tanstack-react-query/handles.md#nextjs-app-router), minus `'use client'`:

```tsx title="src/components/post.tsx"
import { useSuspenseQuery } from '@tanstack/react-query';
import { fromHandle } from '@trpc/tanstack-react-query/unstable-handles';
import type { inferQueryHandle } from '@trpc/tanstack-react-query/unstable-handles';
import { useTRPC, type TRPC } from '~/lib/trpc';

export function Post(props: { post: inferQueryHandle<TRPC['post']['byId']> }) {
  const trpc = useTRPC();
  const postQuery = useSuspenseQuery(fromHandle(trpc, props.post));
  return <h1>{postQuery.data.title}</h1>;
}
```

```
route loader
├─ prefetchEntry(trpc.post.byId, { id })            → post handle     → <Post post={post} />
└─ prefetchInfiniteEntry(trpc.post.comments, …)     → comments handle → <Comments comments={comments} />
```

## Start every query from the URL {#url-as-input}

A loader only sees the URL, so give it everything a page's queries need: path params for what the page is about, and search params for filters, sort and page. With `validateSearch` and `loaderDeps`, the loader starts the right query on every navigation, before the route renders:

```tsx title="src/routes/posts.tsx"
import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { loadHandles, prefetchEntry, fromHandle } from '@trpc/tanstack-react-query/unstable-handles';
import { z } from 'zod';
import { useTRPC } from '../lib/trpc';

export const Route = createFileRoute('/posts')({
  validateSearch: z.object({
    sort: z.enum(['new', 'top']).default('new'),
    page: z.number().int().positive().default(1),
  }),
  loaderDeps: ({ search }) => search,
  loader: async ({ context: { trpc }, deps }) => {
    const [handles] = await loadHandles(trpc, {
      posts: prefetchEntry(trpc.post.list, deps),
    });
    return handles;
  },
  component: PostsPage,
});

function PostsPage() {
  const trpc = useTRPC();
  const loaderData = Route.useLoaderData();
  const postsQuery = useSuspenseQuery(
    fromHandle(trpc, loaderData.posts),
  );
  return (
    <ul>
      {postsQuery.data.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
```

A link to `/posts?sort=top` starts the query for the top posts as the navigation begins. The back button brings back the last sort, and a shared link opens the same view. Keep the sort in `useState` and you lose all three, since the loader can't see component state.

```
/posts?sort=top&page=2  →  prefetchEntry(trpc.post.list, { sort: 'top', page: 2 })
```

If a query can't take its input from the URL, for example because it needs an id from another query's result, reshape the procedure: see [If the URL isn't enough, reshape the procedure](https://trpc.io/docs/client/tanstack-react-query/handles.md#reshape-procedures).

## Handles in `beforeLoad` {#before-load}

`beforeLoad` can return handles too, for example to start a query in a parent route and read it in child routes. TanStack Router checks that values returned from `beforeLoad` are serializable, and handles pass that check as they are: their query keys and inputs are typed from the procedure.

<details>
<summary>Escape hatch: loading a dependent query before render (TanStack Router)</summary>

`beforeLoad` runs before the loader, so it can wait for the post and hand its data to the loader through the route context. The loader then starts more by the post's author from `post.authorId`:

```tsx title="src/routes/posts.$id.tsx"
import { createFileRoute } from '@tanstack/react-router';
import {
  ensureInfiniteEntry,
  fetchEntry,
  loadHandles,
  prefetchInfiniteEntry,
} from '@trpc/tanstack-react-query/unstable-handles';
import { Comments } from '~/components/comments';
import { MoreByAuthor } from '~/components/more-by-author';
import { Post } from '~/components/post';

export const Route = createFileRoute('/posts/$id')({
  beforeLoad: async ({ context: { trpc }, params }) => {
    const [handles, data] = await loadHandles(trpc, {
      // Fresh, since the loader builds another query's input from it
      post: fetchEntry(trpc.post.byId, { id: params.id }),
      comments: prefetchInfiniteEntry(
        trpc.post.comments,
        { postId: params.id },
        { getNextPageParam: (last) => last.nextCursor },
      ),
    });
    return { postHandles: handles, post: data.post };
  },
  loader: async ({ context: { trpc, postHandles, post } }) => {
    // Starts only after beforeLoad has the post
    const [handles] = await loadHandles(trpc, {
      moreByAuthor: ensureInfiniteEntry(
        trpc.post.byAuthor,
        { authorId: post.authorId },
        { getNextPageParam: (last) => last.nextCursor },
      ),
    });
    return { ...postHandles, ...handles };
  },
  component: PostPage,
});

function PostPage() {
  const loaderData = Route.useLoaderData();
  return (
    <>
      <Post post={loaderData.post} />
      <Comments comments={loaderData.comments} />
      <MoreByAuthor posts={loaderData.moreByAuthor} />
    </>
  );
}
```

This serialises the loads: more by the author can't start until the post has arrived. Prefer one `loadHandles` that starts both from the URL, or [read the dependent query in the component](https://trpc.io/docs/client/tanstack-react-query/handles.md#waterfall-in-a-component), which fetches it in render instead. Reach for `beforeLoad` only when the dependent data must be in the cache before the page renders.

</details>

## Don't return handles from server functions {#server-functions}

A server function runs with its own `QueryClient`, which never reaches the page. Return data from server functions, and load handles in loaders. In development, tRPC warns when it renders a handle whose data never reached the page's `QueryClient`.
