Server Components
Use cRPC in RSCs with prefetching and hydration.
In this guide, we'll explore using cRPC in React Server Components. You'll learn non-blocking prefetch patterns, direct server calls with caller, awaited preloads for metadata, and how hydration bridges server and client.
Overview
cRPC provides a server-compatible proxy for RSCs with non-blocking prefetch - a pattern not available in vanilla Convex:
| Vanilla Convex | better-convex | |
|---|---|---|
| Non-blocking prefetch | Not available | prefetch() |
| Direct server calls | fetchQuery() | caller |
| Awaited + hydrated | preloadQuery() | preloadQuery() |
| Prop drilling | Required | Not needed |
| Client hooks | usePreloadedQuery(preloaded) | Standard useQuery() |
better-convex provides three patterns:
| Pattern | Description |
|---|---|
prefetch | Fire-and-forget, non-blocking, hydrated to client |
caller | Direct server calls, not cached/hydrated (like tRPC) |
preloadQuery | Awaited, returns data + hydrates to client |
Let's explore each one.
Basic Usage
Prefetch queries in Server Components and wrap client components with HydrateClient:
import { crpc, HydrateClient, prefetch } from '@/lib/convex/rsc';
import { PostList } from './post-list';
export default async function PostsPage() {
// Prefetch data on server (fire-and-forget)
prefetch(crpc.posts.list.queryOptions({}));
return (
<HydrateClient>
<PostList />
</HydrateClient>
);
}'use client';
import { useQuery } from '@tanstack/react-query';
import { crpc } from '@/lib/convex/client';
export function PostList() {
// Instant data from server prefetch, then real-time updates
const { data: posts } = useQuery(crpc.posts.list.queryOptions({}));
return (
<ul>
{posts?.map((post) => (
<li key={post._id}>{post.title}</li>
))}
</ul>
);
}Auth-Aware Prefetching
Use skipUnauth to prefetch queries that require authentication without blocking:
import { crpc, HydrateClient, prefetch } from '@/lib/convex/rsc';
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
// Skip if not authenticated, don't error
prefetch(crpc.user.getCurrentUser.queryOptions({}, { skipUnauth: true }));
return <HydrateClient>{children}</HydrateClient>;
}Multiple Prefetches
Prefetch multiple queries in parallel:
import { crpc, HydrateClient, prefetch } from '@/lib/convex/rsc';
export default async function DashboardPage() {
// All prefetch in parallel (fire-and-forget)
prefetch(crpc.user.getCurrentUser.queryOptions({}, { skipUnauth: true }));
prefetch(crpc.posts.list.queryOptions({}));
prefetch(crpc.stats.dashboard.queryOptions({}));
return (
<HydrateClient>
<Dashboard />
</HydrateClient>
);
}caller
Direct server calls - detached from query client, not cached, not hydrated to client. Like tRPC's createCaller, use when you only need server-side access.
import { caller } from '@/lib/convex/rsc';
export default async function AdminPage() {
// Direct call - server only, not hydrated
const user = await caller.user.getSessionUser({});
if (!user?.isAdmin) {
redirect('/');
}
// Continue with admin logic...
return <AdminDashboard />;
}When to use caller
| Use Case | Description |
|---|---|
| Server-side conditionals | Redirects, auth checks where data isn't needed on client |
| API routes and middleware | Server-only logic |
| Sensitive data | Data that shouldn't be exposed to client |
See Server Side Calls for setup.
preloadQuery
Equivalent to Convex's preloadQuery - awaited fetch that returns data on the server. Use when you need server-side access to the data:
import { crpc, HydrateClient, preloadQuery } from '@/lib/convex/rsc';
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Await data - available on server
const post = await preloadQuery(crpc.posts.get.queryOptions({ id }));
if (!post) {
notFound();
}
return (
<HydrateClient>
<h1>{post.title}</h1>
<PostContent post={post} />
</HydrateClient>
);
}When to use preloadQuery
| Use Case | Description |
|---|---|
| Server-side conditionals | 404 checks, redirects |
| Metadata generation | Generate page metadata from the data |
| Server rendering | Render data directly in Server Components |
Metadata Generation
import { crpc, preloadQuery } from '@/lib/convex/rsc';
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await preloadQuery(crpc.posts.get.queryOptions({ id }));
return {
title: post?.title ?? 'Post Not Found',
description: post?.excerpt,
};
}Data Ownership and Revalidation
Important: From the TanStack Query perspective, treat Server Components as a place to prefetch data, nothing more.
When using preloadQuery (fetchQuery), be aware of data ownership:
// AVOID: Rendering fetched data in both Server and Client components
export default async function PostsPage() {
const posts = await preloadQuery(crpc.posts.list.queryOptions({}));
return (
<HydrateClient>
{/* Server-rendered - React Query can't revalidate this */}
<div>Nr of posts: {posts.length}</div>
{/* Client-rendered - React Query CAN revalidate this */}
<PostList />
</HydrateClient>
);
}The problem: When React Query revalidates on the client, the PostList component updates but Nr of posts: {posts.length} stays stale - React Query has no way to revalidate the Server Component.
Best practices:
| Practice | Reason |
|---|---|
Prefer prefetch | Let client components own the data |
Use preloadQuery only when needed | For metadata, 404 checks, redirects |
| Don't render fetched data in Server Components | If client components also use it |
| If you must use both | Understand they can get out of sync after revalidation |
See TanStack Query Advanced SSR Guide for more details.
Comparison
| Feature | prefetch | caller | preloadQuery |
|---|---|---|---|
| Convex equivalent | None (new) | fetchQuery | preloadQuery |
| Blocking | No | Yes | Yes |
| Returns data | No (void) | Yes | Yes |
| Client hydration | Yes | No | Yes |
| Revalidation safe | Yes | N/A | Requires care |
| Use case | Client-only data | Server-only logic | Server + client data |
How It Works
Let's understand how hydration bridges server and client.
HydrateClient dehydrates the prefetched queries so client components receive data instantly:
- Server prefetches queries into the QueryClient
HydrateClientserializes the cache viadehydrate()HydrationBoundaryrestores data on the client- Client components get instant data, then subscribe for real-time updates
Query Key Matching
The server and client proxies generate identical query keys, ensuring prefetched data is found:
// Server (RSC)
crpc.posts.list.queryOptions({ limit: 10 });
// queryKey: ['convexQuery', funcRef, { limit: 10 }]
// Client (same key)
crpc.posts.list.queryOptions({ limit: 10 });
// queryKey: ['convexQuery', funcRef, { limit: 10 }]Important Notes
HydrateClient Placement
HydrateClient must wrap all client components that use prefetched queries:
// Correct - HydrateClient wraps client component
prefetch(crpc.posts.list.queryOptions({}));
return (
<HydrateClient>
<PostList />
</HydrateClient>
);// Wrong - PostList renders before hydration
return (
<>
<PostList />
<HydrateClient>
<OtherComponent />
</HydrateClient>
</>
);Migrate from Convex
If you're coming from vanilla Convex, here's what changes.
What stays the same
- Server-side data fetching
- Server-side preloading via
preloadQuery
What's new
Before (vanilla Convex):
import { preloadQuery } from 'convex/nextjs';
import { api } from '@convex/_generated/api';
import { Preloaded, usePreloadedQuery } from 'convex/react';
// Server Component
export default async function Page() {
const preloaded = await preloadQuery(api.posts.list);
return <PostList preloadedPosts={preloaded} />;
}
// Client Component
function PostList({ preloadedPosts }: { preloadedPosts: Preloaded<typeof api.posts.list> }) {
const posts = usePreloadedQuery(preloadedPosts);
return /* ... */;
}After (cRPC):
import { crpc, HydrateClient, prefetch } from '@/lib/convex/rsc';
// Server Component
export default async function Page() {
prefetch(crpc.posts.list.queryOptions({}));
return (
<HydrateClient>
<PostList />
</HydrateClient>
);
}
// Client Component
function PostList() {
const { data: posts } = useQuery(crpc.posts.list.queryOptions({}));
return /* ... */;
}Key differences:
| Feature | Description |
|---|---|
| No prop drilling | Preloaded data doesn't need to be passed as props |
| Standard hooks | Client components use standard useQuery hooks |
| Non-blocking | prefetch is fire-and-forget |
| Same options | Same query options as client-side code |
| Automatic hydration | Via HydrateClient |
| Auth configured once | Via getToken in QueryClient options |