# 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: TRPC }>()`.

## 5. Start queries in loaders

A loader starts queries and returns their handles as loader data. For each query, choose whether the loader waits for it. [Starting a query](https://trpc.io/docs/client/tanstack-react-query/handles.md#starting-a-query) compares the two. 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: `unstable_$prefetch` {#loader-prefetch}

The loader returns 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 { Suspense } from 'react';
import { Comments } from '~/components/comments';

export const Route = createFileRoute('/posts/$id/comments')({
  loader: ({ context: { trpc }, params }) => ({
    comments: trpc.post.comments.unstable_$prefetchInfinite(
      { postId: params.id, limit: 20 },
      { getNextPageParam: (last) => last.nextCursor },
    ),
  }),
  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 loader never throws 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: `unstable_$ensure` {#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 { isTRPCClientError } from '@trpc/client';
import { Post } from '~/components/post';

export const Route = createFileRoute('/posts/$id')({
  loader: async ({ context: { trpc }, params }) => {
    const post = await trpc.post.byId
      .unstable_$ensure({ id: params.id })
      .catch((cause) => {
        if (isTRPCClientError(cause) && cause.data?.code === 'NOT_FOUND') {
          throw notFound();
        }
        throw cause;
      });
    return { post };
  },
  component: PostPage,
});

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

- **No `Suspense` boundary is needed** for `Post`: the data is in the cache before the route renders.
- 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 with `await context.queryClient.ensureQueryData(context.trpc.unstable_$fromHandle(post))` 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, see [Combining both](https://trpc.io/docs/client/tanstack-react-query/handles.md#combining-both): start every query first, then await.

## 6. Read handles in components

Components read handles with `unstable_$fromHandle` from `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 type { unstable_inferQueryHandle } from '@trpc/tanstack-react-query';
import { useTRPC, type TRPC } from '~/lib/trpc';

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

```
route loader
├─ trpc.post.byId.unstable_$prefetch({ id })        → post handle     → <Post post={post} />
└─ trpc.post.comments.unstable_$prefetchInfinite()  → 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 { 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: ({ context: { trpc }, deps }) => ({
    posts: trpc.post.list.unstable_$prefetch(deps),
  }),
  component: PostsPage,
});

function PostsPage() {
  const trpc = useTRPC();
  const loaderData = Route.useLoaderData();
  const postsQuery = useSuspenseQuery(
    trpc.unstable_$fromHandle(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  →  trpc.post.list.unstable_$prefetch({ 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.

## 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 start queries in loaders. In development, tRPC warns when it renders a handle whose data never reached the page's `QueryClient`.
