React Server Components
Prefetch in server components, stream pending queries to the browser, and tag Next's data cache by query key.
On this page
createTrpcRsc from @trpc12/tanstack-react-query/rsc gives server components query helpers over the router itself, a per-request query client, and a server HydrationBoundary:
// trpc/server.ts
import 'server-only';
import { createTrpcRsc } from '@trpc12/tanstack-react-query/rsc';
export const { trpc, caller, getQueryClient, prefetch, HydrationBoundary } =
createTrpcRsc({
router: appRouter,
ctx: createContext, // called once per request
});// app/post/[id]/page.tsx
export default async function function Page(props: {
params: Promise<{
id: string;
}>;
}): Promise<React.JSX.Element>
Page(props: {
params: Promise<{
id: string;
}>;
}
props: { params: Promise<{
id: string;
}>
params: interface Promise<T>Promise<{ id: stringid: string }> }) {
const const params: {
id: string;
}
params = await props: {
params: Promise<{
id: string;
}>;
}
props.params: Promise<{
id: string;
}>
params;
const prefetch: (options: QueryExecuteOptions<any, any, any, any, any>) => voidprefetch(const trpc: TrpcQueryUtils<TrpcClient<{
post: {
byId: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}>;
list: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: undefined;
output: Post[];
errors: never;
services: never;
}>;
create: Procedure<{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: Post;
errors: TrpcError<...> | SchemaValidationError<...>;
services: never;
}>;
};
}, {}, DansonForm>>
trpc.post: TrpcQueryUtils<ClientRouter<{
byId: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}>;
list: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: undefined;
output: Post[];
errors: never;
services: never;
}>;
create: Procedure<{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: Post;
errors: TrpcError<...> | SchemaValidationError<...>;
services: never;
}>;
}, {}, DansonForm, {}>>
post.byId: QueryProcedureUtils<{
id: string;
}, {
id: string;
title: string;
author: string | null;
createdAt: Date;
}, NonNullable<(TrpcError<"NOT_FOUND", undefined> & {
readonly defined: false;
}) | (TrpcError<"BAD_REQUEST", undefined> & {
readonly defined: false;
}) | (TrpcError<"UNAUTHORIZED", undefined> & {
readonly defined: false;
}) | (TrpcError<"PARSE_ERROR", undefined> & {
readonly defined: false;
}) | (TrpcError<"UNSUPPORTED_PROTOCOL", undefined> & {
readonly defined: false;
}) | (TrpcError<"PAYMENT_REQUIRED", undefined> & {
readonly defined: false;
}) | ... 20 more ... | (TrpcError<...> & {
...;
})>, Omit<...>>
byId.QueryProcedureUtils<{ id: string; }, { id: string; title: string; author: string | null; createdAt: Date; }, NonNullable<(TrpcError<"NOT_FOUND", undefined> & { readonly defined: false; }) | ... 25 more ... | (TrpcError<...> & { ...; })>, Omit<...>>.queryOptions<any>(opts: {
input: {
id: string;
} | unique symbol;
} & ContextBag<Omit<NonNullable<{
signal?: AbortSignal | undefined;
} & BuiltinCallOptions>, "signal" | "lastEventId">> & QueryOptionsIn<{
id: string;
title: string;
author: string | null;
createdAt: Date;
}, NonNullable<(TrpcError<"NOT_FOUND", undefined> & {
readonly defined: false;
}) | (TrpcError<"BAD_REQUEST", undefined> & {
readonly defined: false;
}) | (TrpcError<"UNAUTHORIZED", undefined> & {
readonly defined: false;
}) | ... 23 more ... | (TrpcError<...> & {
...;
})>, any>): QueryOptionsOut<...> (+1 overload)
queryOptions({ input: {
id: string;
} | unique symbol
input: { id: stringid: const params: {
id: string;
}
params.id: stringid } }));
return (
<const HydrationBoundary: (props: {
children?: React.ReactNode;
}) => React.ReactNode
HydrationBoundary>
{/* a client component using useSuspenseQuery */}
<function Post(props: {
id: string;
}): React.ReactNode
Post id: stringid={const params: {
id: string;
}
params.id: stringid} />
</const HydrationBoundary: (props: {
children?: React.ReactNode;
}) => React.ReactNode
HydrationBoundary>
);
}ctxand the query client are memoized per request with React'scache().HydrationBoundarydehydrates the request's queries, pending ones included, so they stream in. The browser'sQueryClientneedsdefaultOptions: hydrationOptions({ client })(Hydration). Read a prefetch you don'tawaitwithuseSuspenseQuery: withuseQuery, the server can render the loading state while the browser already has the data, and React reports a hydration mismatch. For a component onuseQuery, wait for the data instead:await getQueryClient().query(…). If the router declares a serializer,createTrpcRscrequires the same one asserializer, soDates and custom types survive.await getQueryClient().query(…)reads data in the server component itself.callercalls the router without the cache.- A pending query that later fails is redacted in the dehydrated state, unless its error has a
digest: Next has to see those to detect dynamic rendering. Setdehydrate.shouldRedactErrorson yourmakeQueryClientto change that.
Next.js data cache
cacheTags(key) turns a query key into data-cache tags, broad to narrow: trpc, each path prefix (trpc:post, trpc:post.byId), then the path with its input (trpc:post.byId:{"id":"1"}, keys sorted). revalidationTag(key) is the one tag that covers a key, so revalidating works like invalidateQueries. Both come from @trpc12/tanstack-query and every framework package; in server components and Server Actions, import them from @trpc12/tanstack-react-query/rsc, since the package root imports React's client hooks.
Tag a 'use cache' function (Next's cacheComponents: true) that reads through caller, then revalidate after a write:
import { function cacheLife(profile: "default"): void (+8 overloads)cacheLife, function cacheTag(...tags: string[]): voidcacheTag, function revalidateTag(tag: string, profile: string | CacheLifeConfig): undefinedrevalidateTag, function updateTag(tag: string): undefinedupdateTag } from 'next/cache';
import { function cacheTags(key: TrpcQueryKey): string[]cacheTags, function revalidationTag(key: TrpcQueryKey): stringrevalidationTag } from '@trpc12/tanstack-react-query/rsc';
async function function getPost(id: string): Promise<{
id: string;
title: string;
author: string | null;
createdAt: Date;
}>
getPost(id: stringid: string) {
'use cache';
function cacheLife(profile: "max"): void (+8 overloads)cacheLife('max');
function cacheTag(...tags: string[]): voidcacheTag(...function cacheTags(key: TrpcQueryKey): string[]cacheTags(const trpc: TrpcQueryUtils<TrpcClient<{
post: {
byId: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}>;
list: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: undefined;
output: Post[];
errors: never;
services: never;
}>;
create: Procedure<{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: Post;
errors: TrpcError<...> | SchemaValidationError<...>;
services: never;
}>;
};
}, {}, DansonForm>>
trpc.post: TrpcQueryUtils<ClientRouter<{
byId: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}>;
list: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: undefined;
output: Post[];
errors: never;
services: never;
}>;
create: Procedure<{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: Post;
errors: TrpcError<...> | SchemaValidationError<...>;
services: never;
}>;
}, {}, DansonForm, {}>>
post.byId: QueryProcedureUtils<{
id: string;
}, {
id: string;
title: string;
author: string | null;
createdAt: Date;
}, NonNullable<(TrpcError<"NOT_FOUND", undefined> & {
readonly defined: false;
}) | (TrpcError<"BAD_REQUEST", undefined> & {
readonly defined: false;
}) | (TrpcError<"UNAUTHORIZED", undefined> & {
readonly defined: false;
}) | (TrpcError<"PARSE_ERROR", undefined> & {
readonly defined: false;
}) | (TrpcError<"UNSUPPORTED_PROTOCOL", undefined> & {
readonly defined: false;
}) | (TrpcError<"PAYMENT_REQUIRED", undefined> & {
readonly defined: false;
}) | ... 20 more ... | (TrpcError<...> & {
...;
})>, Omit<...>>
byId.QueryProcedureUtils<{ id: string; }, { id: string; title: string; author: string | null; createdAt: Date; }, NonNullable<(TrpcError<"NOT_FOUND", undefined> & { readonly defined: false; }) | ... 25 more ... | (TrpcError<...> & { ...; })>, Omit<...>>.queryKey(opts: {
input: {
id: string;
} | unique symbol;
}): TrpcQueryKey & {
[dataTagSymbol]: {
id: string;
title: string;
author: string | null;
createdAt: Date;
};
[dataTagErrorSymbol]: NonNullable<(TrpcError<"NOT_FOUND", undefined> & {
readonly defined: false;
}) | (TrpcError<"BAD_REQUEST", undefined> & {
readonly defined: false;
}) | (TrpcError<"UNAUTHORIZED", undefined> & {
readonly defined: false;
}) | (TrpcError<"PARSE_ERROR", undefined> & {
readonly defined: false;
}) | ... 22 more ... | (TrpcError<...> & {
...;
})>;
}
queryKey({ input: {
id: string;
} | unique symbol
input: { id: stringid } })));
return const caller: TrpcClient<{
post: {
byId: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}>;
list: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: undefined;
output: Post[];
errors: never;
services: never;
}>;
create: Procedure<{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: Post;
errors: TrpcError<...> | SchemaValidationError<...>;
services: never;
}>;
};
}, {}, DansonForm>
caller.post: ClientRouter<{
byId: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}>;
list: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: undefined;
output: Post[];
errors: never;
services: never;
}>;
create: Procedure<{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: Post;
errors: TrpcError<...> | SchemaValidationError<...>;
services: never;
}>;
}, {}, DansonForm, {}>
post.byId: {
query(input: {
id: string;
}, opts?: CallOptions<{}> | undefined): TrpcPromise<{
id: string;
title: string;
author: string | null;
createdAt: Date;
}, CallError<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}, DansonForm, never>>;
}
byId.function query(input: {
id: string;
}, opts?: CallOptions<{}> | undefined): TrpcPromise<{
id: string;
title: string;
author: string | null;
createdAt: Date;
}, CallError<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}, DansonForm, never>>
query({ id: stringid });
}
// in a Server Action, after a write: the refreshed page waits for fresh data
function updateTag(tag: string): undefinedupdateTag(function revalidationTag(key: TrpcQueryKey): stringrevalidationTag(const trpc: TrpcQueryUtils<TrpcClient<{
post: {
byId: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}>;
list: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: undefined;
output: Post[];
errors: never;
services: never;
}>;
create: Procedure<{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: Post;
errors: TrpcError<...> | SchemaValidationError<...>;
services: never;
}>;
};
}, {}, DansonForm>>
trpc.post: TrpcQueryUtils<ClientRouter<{
byId: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}>;
list: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: undefined;
output: Post[];
errors: never;
services: never;
}>;
create: Procedure<{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: Post;
errors: TrpcError<...> | SchemaValidationError<...>;
services: never;
}>;
}, {}, DansonForm, {}>>
post.byId: QueryProcedureUtils<{
id: string;
}, {
id: string;
title: string;
author: string | null;
createdAt: Date;
}, NonNullable<(TrpcError<"NOT_FOUND", undefined> & {
readonly defined: false;
}) | (TrpcError<"BAD_REQUEST", undefined> & {
readonly defined: false;
}) | (TrpcError<"UNAUTHORIZED", undefined> & {
readonly defined: false;
}) | (TrpcError<"PARSE_ERROR", undefined> & {
readonly defined: false;
}) | (TrpcError<"UNSUPPORTED_PROTOCOL", undefined> & {
readonly defined: false;
}) | (TrpcError<"PAYMENT_REQUIRED", undefined> & {
readonly defined: false;
}) | ... 20 more ... | (TrpcError<...> & {
...;
})>, Omit<...>>
byId.QueryProcedureUtils<{ id: string; }, { id: string; title: string; author: string | null; createdAt: Date; }, NonNullable<(TrpcError<"NOT_FOUND", undefined> & { readonly defined: false; }) | ... 25 more ... | (TrpcError<...> & { ...; })>, Omit<...>>.queryKey(opts: {
input: {
id: string;
} | unique symbol;
}): TrpcQueryKey & {
[dataTagSymbol]: {
id: string;
title: string;
author: string | null;
createdAt: Date;
};
[dataTagErrorSymbol]: NonNullable<(TrpcError<"NOT_FOUND", undefined> & {
readonly defined: false;
}) | (TrpcError<"BAD_REQUEST", undefined> & {
readonly defined: false;
}) | (TrpcError<"UNAUTHORIZED", undefined> & {
readonly defined: false;
}) | (TrpcError<"PARSE_ERROR", undefined> & {
readonly defined: false;
}) | ... 22 more ... | (TrpcError<...> & {
...;
})>;
}
queryKey({ input: {
id: string;
} | unique symbol
input: { id: stringid } }))); // that post
function updateTag(tag: string): undefinedupdateTag(function revalidationTag(key: TrpcQueryKey): stringrevalidationTag(const trpc: TrpcQueryUtils<TrpcClient<{
post: {
byId: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}>;
list: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: undefined;
output: Post[];
errors: never;
services: never;
}>;
create: Procedure<{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: Post;
errors: TrpcError<...> | SchemaValidationError<...>;
services: never;
}>;
};
}, {}, DansonForm>>
trpc.post: TrpcQueryUtils<ClientRouter<{
byId: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}>;
list: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: undefined;
output: Post[];
errors: never;
services: never;
}>;
create: Procedure<{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: Post;
errors: TrpcError<...> | SchemaValidationError<...>;
services: never;
}>;
}, {}, DansonForm, {}>>
post.PathUtils.pathKey(): TrpcQueryKeypathKey())); // every post.* call
// in a route handler or webhook: stale-while-revalidate
function revalidateTag(tag: string, profile: string | CacheLifeConfig): undefinedrevalidateTag(function revalidationTag(key: TrpcQueryKey): stringrevalidationTag(const trpc: TrpcQueryUtils<TrpcClient<{
post: {
byId: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}>;
list: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: undefined;
output: Post[];
errors: never;
services: never;
}>;
create: Procedure<{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: Post;
errors: TrpcError<...> | SchemaValidationError<...>;
services: never;
}>;
};
}, {}, DansonForm>>
trpc.post: TrpcQueryUtils<ClientRouter<{
byId: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: {
id: string;
};
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | SchemaValidationError<ZodObject<{
id: ZodString;
}, $strip>>;
services: never;
}>;
list: Procedure<{
type: "query";
ctx: Context;
meta: {};
input: undefined;
output: Post[];
errors: never;
services: never;
}>;
create: Procedure<{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: Post;
errors: TrpcError<...> | SchemaValidationError<...>;
services: never;
}>;
}, {}, DansonForm, {}>>
post.PathUtils.pathKey(): TrpcQueryKeypathKey()), 'max');updateTagonly works in Server Actions. A write that goes through the tRPC route handler doesn't revalidate anything by itself, so cached pages keep the old data until something revalidates their tags.- An input whose tag would be longer than Next's 256 characters gets no tag of its own; it revalidates with its path.
The examples/next-app guestbook puts these together: guestbook.tsx caches the list under cacheTags, and the Server Action in actions.ts calls updateTag(revalidationTag(trpc.guestbook.pathKey())) after signing.