Skip to content
tRPC

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
>
); }
  • ctx and the query client are memoized per request with React's cache().
  • HydrationBoundary dehydrates the request's queries, pending ones included, so they stream in. The browser's QueryClient needs defaultOptions: hydrationOptions({ client }) (Hydration). Read a prefetch you don't await with useSuspenseQuery: with useQuery, the server can render the loading state while the browser already has the data, and React reports a hydration mismatch. For a component on useQuery, wait for the data instead: await getQueryClient().query(…). If the router declares a serializer, createTrpcRsc requires the same one as serializer, so Dates and custom types survive.
  • await getQueryClient().query(…) reads data in the server component itself. caller calls 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. Set dehydrate.shouldRedactErrors on your makeQueryClient to 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.* call

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 app puts these together with Server Actions.