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: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
id: ZodString;
}, $strip>>, "query", {
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}>;
services: never;
unmapped: never;
}>;
list: BuildProcedure<RootDef<Context, {}>, "query", {
output: Post[];
errors: never;
services: never;
unmapped: never;
}>;
create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
ctx: Context;
ctxAdded: {
...;
};
inputOut: Unset;
errors: TrpcError<...>;
shorts: never;
}>, ZodObject<...>>, "mutation", {
...;
}>;
};
}, {}, DansonForm>>
trpc.post: TrpcQueryUtils<ClientRouter<{
byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
id: ZodString;
}, $strip>>, "query", {
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}>;
services: never;
unmapped: never;
}>;
list: BuildProcedure<RootDef<Context, {}>, "query", {
output: Post[];
errors: never;
services: never;
unmapped: never;
}>;
create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
ctx: Context;
ctxAdded: {
...;
};
inputOut: Unset;
errors: TrpcError<...>;
shorts: never;
}>, ZodObject<...>>, "mutation", {
...;
}>;
}, {}, DansonForm, {}>>
post.byId: QueryProcedureUtils<{
id: string;
}, {
id: string;
title: string;
author: string | null;
createdAt: Date;
}, NonNullable<UnexpectedError | (TrpcError<"NOT_FOUND", {
id: string;
}> & {
readonly defined: true;
}) | (TrpcError<"BAD_REQUEST", InputValidationErrorData<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["id"];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
... 4 more ...;
readonly message: string;
} | ... 9 more ... | {
...;
}>> & {
...;
})>, Omit<...>>
byId.QueryProcedureUtils<{ id: string; }, { id: string; title: string; author: string | null; createdAt: Date; }, NonNullable<UnexpectedError | (TrpcError<"NOT_FOUND", { id: string; }> & { ...; }) | (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<UnexpectedError | (TrpcError<"NOT_FOUND", {
id: string;
}> & {
readonly defined: true;
}) | (TrpcError<"BAD_REQUEST", InputValidationErrorData<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly [...];
readonly message: string;
} | ... 10 more ... | {
...;
}>> & {
...;
})>, 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) from @trpc12/tanstack-query (re-exported by the framework packages) 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:
import { function cacheTag(...tags: string[]): voidcacheTag, function revalidateTag(tag: string, profile: string | CacheLifeConfig): undefinedrevalidateTag } from 'next/cache';
import { function cacheTags(key: TrpcQueryKey): string[]cacheTags, function revalidationTag(key: TrpcQueryKey): stringrevalidationTag } from '@trpc12/tanstack-react-query';
async function function getPost(id: string): Promise<{
id: string;
title: string;
author: string | null;
createdAt: Date;
}>
getPost(id: stringid: string) {
'use cache';
function cacheTag(...tags: string[]): voidcacheTag(...function cacheTags(key: TrpcQueryKey): string[]cacheTags(const trpc: TrpcQueryUtils<TrpcClient<{
post: {
byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
id: ZodString;
}, $strip>>, "query", {
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}>;
services: never;
unmapped: never;
}>;
list: BuildProcedure<RootDef<Context, {}>, "query", {
output: Post[];
errors: never;
services: never;
unmapped: never;
}>;
create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
ctx: Context;
ctxAdded: {
...;
};
inputOut: Unset;
errors: TrpcError<...>;
shorts: never;
}>, ZodObject<...>>, "mutation", {
...;
}>;
};
}, {}, DansonForm>>
trpc.post: TrpcQueryUtils<ClientRouter<{
byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
id: ZodString;
}, $strip>>, "query", {
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}>;
services: never;
unmapped: never;
}>;
list: BuildProcedure<RootDef<Context, {}>, "query", {
output: Post[];
errors: never;
services: never;
unmapped: never;
}>;
create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
ctx: Context;
ctxAdded: {
...;
};
inputOut: Unset;
errors: TrpcError<...>;
shorts: never;
}>, ZodObject<...>>, "mutation", {
...;
}>;
}, {}, DansonForm, {}>>
post.byId: QueryProcedureUtils<{
id: string;
}, {
id: string;
title: string;
author: string | null;
createdAt: Date;
}, NonNullable<UnexpectedError | (TrpcError<"NOT_FOUND", {
id: string;
}> & {
readonly defined: true;
}) | (TrpcError<"BAD_REQUEST", InputValidationErrorData<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["id"];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
... 4 more ...;
readonly message: string;
} | ... 9 more ... | {
...;
}>> & {
...;
})>, Omit<...>>
byId.QueryProcedureUtils<{ id: string; }, { id: string; title: string; author: string | null; createdAt: Date; }, NonNullable<UnexpectedError | (TrpcError<"NOT_FOUND", { id: string; }> & { ...; }) | (TrpcError<...> & { ...; })>, Omit<...>>.queryKey(opts: {
input: {
id: string;
} | unique symbol;
}): TrpcQueryKey & {
[dataTagSymbol]: {
id: string;
title: string;
author: string | null;
createdAt: Date;
};
[dataTagErrorSymbol]: NonNullable<UnexpectedError | (TrpcError<"NOT_FOUND", {
id: string;
}> & {
readonly defined: true;
}) | (TrpcError<"BAD_REQUEST", InputValidationErrorData<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["id"];
readonly message: string;
} | ... 10 more ... | {
...;
}>> & {
...;
})>;
}
queryKey({ input: {
id: string;
} | unique symbol
input: { id: stringid } })));
return const caller: TrpcClient<{
post: {
byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
id: ZodString;
}, $strip>>, "query", {
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}>;
services: never;
unmapped: never;
}>;
list: BuildProcedure<RootDef<Context, {}>, "query", {
output: Post[];
errors: never;
services: never;
unmapped: never;
}>;
create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
ctx: Context;
ctxAdded: {
...;
};
inputOut: Unset;
errors: TrpcError<...>;
shorts: never;
}>, ZodObject<...>>, "mutation", {
...;
}>;
};
}, {}, DansonForm>
caller.post: ClientRouter<{
byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
id: ZodString;
}, $strip>>, "query", {
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}>;
services: never;
unmapped: never;
}>;
list: BuildProcedure<RootDef<Context, {}>, "query", {
output: Post[];
errors: never;
services: never;
unmapped: never;
}>;
create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
ctx: Context;
ctxAdded: {
...;
};
inputOut: Unset;
errors: TrpcError<...>;
shorts: never;
}>, ZodObject<...>>, "mutation", {
...;
}>;
}, {}, 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;
}> | InputValidationError<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["id"];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
... 4 more ...;
readonly message: string;
} | ... 9 more ... | {
...;
}>;
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;
}> | InputValidationError<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["id"];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
... 4 more ...;
readonly message: string;
} | ... 9 more ... | {
...;
}>;
services: never;
}, DansonForm, never>>
query({ id: stringid });
}
// in a Server Action, after a write
function revalidateTag(tag: string, profile: string | CacheLifeConfig): undefinedrevalidateTag(
function revalidationTag(key: TrpcQueryKey): stringrevalidationTag(const trpc: TrpcQueryUtils<TrpcClient<{
post: {
byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
id: ZodString;
}, $strip>>, "query", {
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}>;
services: never;
unmapped: never;
}>;
list: BuildProcedure<RootDef<Context, {}>, "query", {
output: Post[];
errors: never;
services: never;
unmapped: never;
}>;
create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
ctx: Context;
ctxAdded: {
...;
};
inputOut: Unset;
errors: TrpcError<...>;
shorts: never;
}>, ZodObject<...>>, "mutation", {
...;
}>;
};
}, {}, DansonForm>>
trpc.post: TrpcQueryUtils<ClientRouter<{
byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
id: ZodString;
}, $strip>>, "query", {
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}>;
services: never;
unmapped: never;
}>;
list: BuildProcedure<RootDef<Context, {}>, "query", {
output: Post[];
errors: never;
services: never;
unmapped: never;
}>;
create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
ctx: Context;
ctxAdded: {
...;
};
inputOut: Unset;
errors: TrpcError<...>;
shorts: never;
}>, ZodObject<...>>, "mutation", {
...;
}>;
}, {}, DansonForm, {}>>
post.byId: QueryProcedureUtils<{
id: string;
}, {
id: string;
title: string;
author: string | null;
createdAt: Date;
}, NonNullable<UnexpectedError | (TrpcError<"NOT_FOUND", {
id: string;
}> & {
readonly defined: true;
}) | (TrpcError<"BAD_REQUEST", InputValidationErrorData<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["id"];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
... 4 more ...;
readonly message: string;
} | ... 9 more ... | {
...;
}>> & {
...;
})>, Omit<...>>
byId.QueryProcedureUtils<{ id: string; }, { id: string; title: string; author: string | null; createdAt: Date; }, NonNullable<UnexpectedError | (TrpcError<"NOT_FOUND", { id: string; }> & { ...; }) | (TrpcError<...> & { ...; })>, Omit<...>>.queryKey(opts: {
input: {
id: string;
} | unique symbol;
}): TrpcQueryKey & {
[dataTagSymbol]: {
id: string;
title: string;
author: string | null;
createdAt: Date;
};
[dataTagErrorSymbol]: NonNullable<UnexpectedError | (TrpcError<"NOT_FOUND", {
id: string;
}> & {
readonly defined: true;
}) | (TrpcError<"BAD_REQUEST", InputValidationErrorData<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["id"];
readonly message: string;
} | ... 10 more ... | {
...;
}>> & {
...;
})>;
}
queryKey({ input: {
id: string;
} | unique symbol
input: { id: stringid } })),
'max',
); // that post
function revalidateTag(tag: string, profile: string | CacheLifeConfig): undefinedrevalidateTag(function revalidationTag(key: TrpcQueryKey): stringrevalidationTag(const trpc: TrpcQueryUtils<TrpcClient<{
post: {
byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
id: ZodString;
}, $strip>>, "query", {
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}>;
services: never;
unmapped: never;
}>;
list: BuildProcedure<RootDef<Context, {}>, "query", {
output: Post[];
errors: never;
services: never;
unmapped: never;
}>;
create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
ctx: Context;
ctxAdded: {
...;
};
inputOut: Unset;
errors: TrpcError<...>;
shorts: never;
}>, ZodObject<...>>, "mutation", {
...;
}>;
};
}, {}, DansonForm>>
trpc.post: TrpcQueryUtils<ClientRouter<{
byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
id: ZodString;
}, $strip>>, "query", {
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}>;
services: never;
unmapped: never;
}>;
list: BuildProcedure<RootDef<Context, {}>, "query", {
output: Post[];
errors: never;
services: never;
unmapped: never;
}>;
create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
ctx: Context;
ctxAdded: {
...;
};
inputOut: Unset;
errors: TrpcError<...>;
shorts: never;
}>, ZodObject<...>>, "mutation", {
...;
}>;
}, {}, DansonForm, {}>>
post.PathUtils.pathKey(): TrpcQueryKeypathKey()), 'max'); // every post.* callAn 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 app puts these together with Server Actions.