BETTER-CONVEX

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 Convexbetter-convex
Non-blocking prefetchNot availableprefetch()
Direct server callsfetchQuery()caller
Awaited + hydratedpreloadQuery()preloadQuery()
Prop drillingRequiredNot needed
Client hooksusePreloadedQuery(preloaded)Standard useQuery()

better-convex provides three patterns:

PatternDescription
prefetchFire-and-forget, non-blocking, hydrated to client
callerDirect server calls, not cached/hydrated (like tRPC)
preloadQueryAwaited, returns data + hydrates to client

Let's explore each one.

Basic Usage

Prefetch queries in Server Components and wrap client components with HydrateClient:

app/posts/page.tsx
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>
  );
}
app/posts/post-list.tsx
'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:

app/layout.tsx
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:

app/dashboard/page.tsx
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.

app/admin/page.tsx
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 CaseDescription
Server-side conditionalsRedirects, auth checks where data isn't needed on client
API routes and middlewareServer-only logic
Sensitive dataData 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:

app/posts/[id]/page.tsx
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 CaseDescription
Server-side conditionals404 checks, redirects
Metadata generationGenerate page metadata from the data
Server renderingRender data directly in Server Components

Metadata Generation

app/posts/[id]/page.tsx
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:

PracticeReason
Prefer prefetchLet client components own the data
Use preloadQuery only when neededFor metadata, 404 checks, redirects
Don't render fetched data in Server ComponentsIf client components also use it
If you must use bothUnderstand they can get out of sync after revalidation

See TanStack Query Advanced SSR Guide for more details.

Comparison

FeatureprefetchcallerpreloadQuery
Convex equivalentNone (new)fetchQuerypreloadQuery
BlockingNoYesYes
Returns dataNo (void)YesYes
Client hydrationYesNoYes
Revalidation safeYesN/ARequires care
Use caseClient-only dataServer-only logicServer + 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:

  1. Server prefetches queries into the QueryClient
  2. HydrateClient serializes the cache via dehydrate()
  3. HydrationBoundary restores data on the client
  4. 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:

FeatureDescription
No prop drillingPreloaded data doesn't need to be passed as props
Standard hooksClient components use standard useQuery hooks
Non-blockingprefetch is fire-and-forget
Same optionsSame query options as client-side code
Automatic hydrationVia HydrateClient
Auth configured onceVia getToken in QueryClient options

Next Steps

On this page