tRPC
Pre-configured tRPC v11 with end-to-end typesafe API routes, context creation, and optional TanStack Query integration.
server-only is installed in Next.js apps only.
What create-faster adds
Files added on Next.js (see TanStack Start for that stack):
src/trpc/
├── init.ts # createTRPCContext({ headers }) + procedure definitions
├── session-reader.ts # The instance the context reads sessions through (if Better Auth, single repo)
├── client.ts # Standalone tRPC client with httpBatchLink
├── server.tsx # Server-side utilities (RSC caller or Query proxy)
├── query-client.ts # QueryClient factory (if TanStack Query)
├── providers.tsx # TRPCReactProvider (if TanStack Query)
└── routers/
├── _app.ts # Root router combining all routers
└── hello.ts # Example router with Zod validation
src/app/api/trpc/
└── [trpc]/route.ts # Fetch adapter API handler, refuses cross-origin writes
# Turborepo only:
packages/api/
├── src/
│ ├── index.ts # Public exports
│ ├── trpc.ts # Context + procedures
│ ├── root.ts # Root router
│ └── router/
│ └── hello.ts # Example router
├── package.json # @repo/api
└── tsconfig.jsoninit.ts
Context creation and procedure definitions. The context carries the request headers, db if an ORM is selected, and session + protectedProcedure if Better Auth is installed. createTRPCContext takes the request headers explicitly on both stacks and in both repo types, plus the db on Cloudflare D1 ({ headers, db }): the API route passes the request's headers, and server-side callers pass the current request's headers (headers() on Next.js, getRequest().headers on TanStack Start):
import { db } from '@/lib/db';
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
export async function createTRPCContext(opts: { headers: Headers }) {
return {
headers: opts.headers,
db,
};
}
type TRPCContext = Awaited<ReturnType<typeof createTRPCContext>>;
const isDev = process.env.NODE_ENV === 'development';
const t = initTRPC.context<TRPCContext>().create({
transformer: superjson,
isDev,
errorFormatter: ({ shape, error }) => {
if (isDev) return shape;
const data = { ...shape.data };
delete data.stack;
return {
...shape,
message: error.code === 'INTERNAL_SERVER_ERROR' ? 'Internal server error' : shape.message,
data,
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;Outside development, error responses carry no stack trace, and an error a procedure does not throw as a TRPCError (a failed query, a bug) reaches the client as Internal server error, without its message, which can carry SQL or parameters. isDev is the NODE_ENV the build inlines, not tRPC's default, which reads NODE_ENV at runtime: some production servers leave it unset (Next.js on Cloudflare through OpenNext).
The API route (route.ts on Next.js, src/routes/api/trpc/$.ts on TanStack Start) refuses with a 403 any request other than GET and HEAD whose Origin header names another origin, so a page on another site cannot run a mutation with the user's cookies. Requests without Origin (other servers, scripts) pass.
With Better Auth, the context includes session and a protectedProcedure middleware is exported. In a single repo, the context reads sessions through sessionReader from src/trpc/session-reader.ts: the app's own instance on Next.js, a cookie-less createAuth() on TanStack Start.
export const protectedProcedure = publicProcedure.use(async (opts) => {
if (!opts.ctx.session?.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You must be logged in to access this resource',
});
}
return opts.next({
ctx: {
...opts.ctx,
session: opts.ctx.session,
user: opts.ctx.session.user,
},
});
});routers/hello.ts
Example router with Zod validation:
import { z } from 'zod';
import { publicProcedure, router } from '@/trpc/init';
export const helloRouter = router({
greet: publicProcedure
.input(z.object({ name: z.string().min(1) }))
.query(({ input }) => {
return { greeting: `Hello, ${input.name}!` };
}),
});routers/_app.ts
Root router combining all routers with type exports:
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import { router } from '@/trpc/init';
import { helloRouter } from './hello';
export const appRouter = router({
hello: helloRouter,
});
export type AppRouter = typeof appRouter;
export type RouterInput = inferRouterInputs<AppRouter>;
export type RouterOutput = inferRouterOutputs<AppRouter>;server.tsx
Server-side utilities for RSC. Behavior depends on whether TanStack Query is selected:
With TanStack Query — provides createTRPCOptionsProxy for RSC queries with hydration:
import 'server-only';
import type React from 'react';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { createTRPCOptionsProxy, type TRPCQueryOptions } from '@trpc/tanstack-react-query';
import { cache } from 'react';
import { makeQueryClient } from '@/trpc/query-client';
import { headers } from 'next/headers';
import { appRouter } from '@/trpc/routers/_app';
import { createTRPCContext } from '@/trpc/init';
const getQueryClient = cache(makeQueryClient);
const getTRPCContext = cache(async () => createTRPCContext({ headers: await headers() }));
export const trpc = createTRPCOptionsProxy({
ctx: getTRPCContext,
router: appRouter,
queryClient: getQueryClient,
});
export function HydrateClient(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const state = dehydrate(queryClient);
return <HydrationBoundary state={state}>{props.children}</HydrationBoundary>;
}
export function prefetch<T extends ReturnType<TRPCQueryOptions<any>>>(queryOptions: T) {
const queryClient = getQueryClient();
if (queryOptions.queryKey[1]?.type === 'infinite') {
void queryClient.prefetchInfiniteQuery(queryOptions as any);
} else {
void queryClient.prefetchQuery(queryOptions);
}
}Without TanStack Query — provides a direct caller for server-side procedure calls:
import 'server-only';
import { cache } from 'react';
import { headers } from 'next/headers';
import { appRouter } from '@/trpc/routers/_app';
import { createTRPCContext } from '@/trpc/init';
const getTRPCContext = cache(async () => createTRPCContext({ headers: await headers() }));
export const trpc = appRouter.createCaller(getTRPCContext);Turborepo mode:
Creates @repo/api package depending on @repo/db and @repo/auth. App imports from @repo/api. All @/trpc imports become @repo/api imports.
TanStack Start
On TanStack Start, procedures called during server rendering run in memory through unstable_localLink, and the browser calls the API route through httpBatchLink. One client is built per request in getRouter() and passed to routes through the router context. init.ts and the routers are the same as on Next.js.
Files added:
src/trpc/
├── init.ts # createTRPCContext({ headers }) + procedure definitions
├── client.ts # makeTRPCClient(): localLink on the server, httpBatchLink in the browser
├── session-reader.ts # Cookie-less auth instance for the context (if Better Auth, single repo)
├── providers.tsx # TRPCProvider, useTRPC, useTRPCClient (if TanStack Query)
└── routers/
├── _app.ts
└── hello.ts
src/routes/api/trpc/
└── $.ts # GET and POST through fetchRequestHandlerModified files:
src/router.tsx-getRouter()builds the per-request client and puts it in the router context; with TanStack Query, next to the query client that TanStack Query builds, it adds the options proxy and wraps the app inTRPCProvidersrc/lib/query-client.ts(TanStack Query's) -makeQueryClient()adds the superjson serializers tRPC needssrc/routes/__root.tsx- Types the router context withcreateRootRouteWithContext
export const makeTRPCClient = createIsomorphicFn()
.server(() => {
let context: ReturnType<typeof createTRPCContext> | undefined;
return createTRPCClient<AppRouter>({
links: [
unstable_localLink({
router: appRouter,
transformer: superjson,
createContext: () => {
if (!context) context = createTRPCContext({ headers: getRequest().headers });
return context;
},
}),
],
});
})
.client(() =>
createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: '/api/trpc', transformer: superjson })],
}),
);The server client memoizes the context promise, so the procedures of one request share one context; createIsomorphicFn keeps the router, the database client and the auth instance out of the client bundle.
With TanStack Query, getRouter() builds the client and the options proxy per request next to TanStack Query's QueryClient, and the router context holds { queryClient, trpc }. The SSR query integration sends the server's queries to the browser with the superjson serializers of makeQueryClient(), streaming the ones still pending. Prefetch in the loader without awaiting, and read the query with useSuspenseQuery inside a Suspense boundary:
export const Route = createFileRoute('/greeting')({
loader: ({ context }) => {
void context.queryClient.prefetchQuery(context.trpc.hello.greet.queryOptions({ name: 'world' }));
},
component: () => (
<Suspense fallback={<p>Loading</p>}>
<Greeting />
</Suspense>
),
});
function Greeting() {
const trpc = useTRPC();
const { data } = useSuspenseQuery(trpc.hello.greet.queryOptions({ name: 'world' }));
return <p>{data.greeting}</p>;
}Without TanStack Query, the router context holds the client as trpc: context.trpc.hello.greet.query({ name: 'world' }) in a loader.
With Better Auth, the context reads the session through getRequestSession(headers, load), the per-request cache the getSession server function also reads, so a request calling both looks the session up once. It reads through a cookie-less instance that never refreshes the session (src/trpc/session-reader.ts in a single repo, packages/api/src/auth.ts in a turborepo, createAuth(db) per request on D1): a procedure prefetched without awaiting runs after the response headers are sent, where a refreshed cookie could not reach the browser.
On Cloudflare with D1, the API route and the server client pass the module-level db from src/lib/server.ts to the context, next to the headers. PostgreSQL and MySQL through Hyperdrive stay disabled with tRPC, like on Next.js.

