Create FasterCreate Faster
API

tRPC

Pre-configured tRPC v11 with end-to-end typesafe API routes, context creation, and optional TanStack Query integration.

Supported frameworks

server-only is installed in Next.js apps only.

→ tRPC Documentation

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.json

init.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):

src/trpc/init.ts
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's onError writes every unexpected error to the server log in every environment, with the procedure path, the message and the stack, since the client no longer receives them. Expected errors (UNAUTHORIZED, BAD_REQUEST, NOT_FOUND, ...) are logged only in development. The same onError is set where procedures are called on the server without the API route: the unstable_localLink of src/trpc/client.ts on TanStack Start, and src/trpc/server.tsx on Next.js. A prefetch that fails is otherwise silent on the server: TanStack Query does not rethrow it, and the browser runs the query again through the API route.

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:

src/trpc/routers/hello.ts
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:

src/trpc/routers/_app.ts
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:

src/trpc/server.tsx
import 'server-only';
import type React from 'react';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { createTRPCClient, unstable_localLink } from '@trpc/client';
import { createTRPCOptionsProxy, type TRPCQueryOptions } from '@trpc/tanstack-react-query';
import superjson from 'superjson';
import { cache } from 'react';
import { makeQueryClient } from '@/trpc/query-client';
import { headers } from 'next/headers';
import { type AppRouter, appRouter } from '@/trpc/routers/_app';
import { createTRPCContext } from '@/trpc/init';

export const getQueryClient = cache(makeQueryClient);

const getTRPCContext = cache(async () => createTRPCContext({ headers: await headers() }));

export const trpc = createTRPCOptionsProxy({
  client: createTRPCClient<AppRouter>({
    links: [
      unstable_localLink({
        router: appRouter,
        transformer: superjson,
        createContext: getTRPCContext,
        onError: ({ path, error }) => {
          // logs unexpected errors, see the error handling above
        },
      }),
    ],
  }),
  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);
  }
}

Prefetching makes the page dynamic. createTRPCContext reads headers(), so a page that prefetches depends on the request. prefetch is not awaited: on a static page, TanStack Query turns the dynamic-usage error of headers() into a rejected query that is dehydrated to the client, and next build fails with Error occurred prerendering page (the error is redacted) instead of switching the route to dynamic. Make the page dynamic, either with the route segment option or by awaiting connection() before the prefetch:

src/app/page.tsx
import { connection } from 'next/server';
import { HydrateClient, prefetch, trpc } from '@/trpc/server';

export default async function Page() {
  await connection();
  prefetch(trpc.hello.greet.queryOptions({ name: 'world' }));
  return <HydrateClient>{/* components reading the query with useSuspenseQuery */}</HydrateClient>;
}
export const dynamic = 'force-dynamic';

A page under a layout that already reads the request (headers(), cookies(), the session) is dynamic already, which is the case of the blueprints' dashboards.

A page for one record awaits it. A prefetch cannot answer 404: a record that does not exist is dehydrated as pending and rejects in the browser. The page loads the record through the exported getQueryClient, turns null into notFound(), and hydrates the loaded data. The procedure returns null for an unknown id instead of throwing, since the server link logs a thrown error in development.

src/app/users/[id]/page.tsx
import { notFound } from 'next/navigation';
import { getQueryClient, HydrateClient, trpc } from '@/trpc/server';

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const user = await getQueryClient().fetchQuery(trpc.user.getById.queryOptions({ id }));
  if (!user) notFound();
  return <HydrateClient>{/* components reading the query with useSuspenseQuery */}</HydrateClient>;
}

Without TanStack Query — provides a direct caller for server-side procedure calls:

src/trpc/server.tsx
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, {
  onError: ({ path, error }) => {
    // logs unexpected errors, see the error handling above
  },
});

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 fetchRequestHandler

Modified 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 in TRPCProvider
  • src/lib/query-client.ts (TanStack Query's) - makeQueryClient() adds the superjson serializers tRPC needs
  • src/routes/__root.tsx - Types the router context with createRootRouteWithContext
src/trpc/client.ts
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;
          },
          onError: ({ path, error }) => {
            // logs unexpected errors, see the error handling above
          },
        }),
      ],
    });
  })
  .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:

src/routes/greeting.tsx
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.

On this page