Better Convex RSC Integration
What We Did
Section titled “What We Did”Added React Server Components (RSC) integration with Better Convex to enable server-side data prefetching. Data is now prefetched on the server and hydrated to the client for instant display, improving initial page load performance.
Why RSC Prefetching
Section titled “Why RSC Prefetching”Key benefits:
- Faster Initial Load: Data is fetched on the server during SSR, eliminating client-side loading states
- Non-Blocking Prefetch: Data streams to the client without blocking the page render
- Seamless Hydration: TanStack Query cache is pre-populated, so client components have data immediately
- Auth-Aware: Uses
skipUnauthto safely handle unauthenticated users without errors
Better Convex RSC Patterns:
prefetch()- Fire-and-forget, non-blocking data fetching hydrated to the clientcaller- Direct server calls detached from query client, not cached or hydratedpreloadQuery()- Awaited fetching that returns data on the server
The prefetch() pattern is preferred for most cases as it enables non-blocking streaming while still hydrating data to the client.
Implementation Details
Section titled “Implementation Details”New File: apps/web/lib/convex/rsc.tsx
Section titled “New File: apps/web/lib/convex/rsc.tsx”This file contains all server-side RSC utilities:
import { cache } from "react"import { headers } from "next/headers"import { convexBetterAuth } from "better-convex/auth-nextjs"import { createServerCRPCProxy, getServerQueryClientOptions } from "better-convex/rsc"
// Server-side Better Auth integrationconst { createContext, createCaller } = convexBetterAuth<Api>({ api, meta, convexSiteUrl,})
// Cached context creation (per-request)const createRSCContext = cache(async () => { const heads = await headers() return createContext({ headers: heads })})
// Direct server caller for auth checks/redirectsexport const caller = createCaller(createRSCContext)
// CRPC proxy for prefetchingexport const crpc = createServerCRPCProxy<Api>({ api, meta })
// Cached QueryClient for RSCexport const getQueryClient = cache(() => { return new QueryClient({ defaultOptions: getServerQueryClientOptions({ getToken: async () => { const ctx = await createRSCContext() return ctx.token ?? undefined }, convexSiteUrl, }), })})
// Non-blocking prefetchexport function prefetch(queryOptions: {...}) { const queryClient = getQueryClient() void queryClient.prefetchQuery(queryOptions)}
// Awaited preload for server-side conditionalsexport async function preloadQuery<T>(queryOptions: {...}): Promise<T | undefined> { const queryClient = getQueryClient() return queryClient.fetchQuery(queryOptions)}
// Hydration wrapper componentexport async function HydrateClient({ children }) { const queryClient = getQueryClient() const dehydratedState = dehydrate(queryClient) return <HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary>}Updated: apps/web/app/page.tsx
Section titled “Updated: apps/web/app/page.tsx”The page now uses RSC prefetching:
import { HomePage } from "@/components/home-page"import { HydrateClient, crpc, prefetch } from "@/lib/convex/rsc"
export default async function Home() { // Prefetch things list for authenticated users prefetch(crpc.things.list.staticQueryOptions({}, { skipUnauth: true }))
return ( <HydrateClient> <HomePage /> </HydrateClient> )}Component Refactoring
Section titled “Component Refactoring”Split the monolithic home-page.tsx into focused client components:
apps/web/components/├── home-page.tsx # Auth state wrapper├── things-manager.tsx # CRUD operations for Things└── user-header.tsx # User info and sign outthings-manager.tsx - Extracted reusable upload helper:
async function uploadFileToStorage(file: File, getUploadUrl: () => Promise<string>) { const uploadUrl = await getUploadUrl() const result = await fetch(uploadUrl, { method: "POST", headers: { "Content-Type": file.type }, body: file, }) const { storageId } = await result.json() return storageId as string}Key Dependencies
Section titled “Key Dependencies”No new dependencies - uses existing better-convex RSC exports:
better-convex/auth-nextjs- Server-side auth integrationbetter-convex/rsc- RSC utilities (createServerCRPCProxy,getServerQueryClientOptions)@tanstack/react-query-HydrationBoundary,dehydrate,QueryClient
Integration with Existing Code
Section titled “Integration with Existing Code”How Hydration Works
Section titled “How Hydration Works”- Server:
prefetch()calls populate the QueryClient cache - Server:
HydrateClientserializes the cache viadehydrate() - Client:
HydrationBoundaryrestores data on the client - Client: Components using
useQueryreceive instant data, then subscribe for real-time updates
Query Key Matching
Section titled “Query Key Matching”Server and client proxies generate identical query keys, ensuring prefetched data is discovered correctly by client components. The @repo/api hooks use the same crpc.things.list.queryOptions({}) pattern, so the cache keys match.
Context for AI
Section titled “Context for AI”When working with RSC prefetching:
- Use
prefetch()for most cases - Non-blocking, streams data to client - Use
preloadQuery()only when needed - For 404 checks, metadata generation, or server-side conditionals - Always wrap client components with
HydrateClient- Required for hydration to work - Use
skipUnauth: truefor auth-protected queries - Prevents errors when user is not logged in - Use
staticQueryOptionsinstead ofqueryOptionsin RSC - Static version doesn’t depend on React hooks - Don’t render prefetched data in RSC - Let client components own the data to avoid desync after revalidation
Outcomes
Section titled “Outcomes”Before
Section titled “Before”- Client-side only data fetching
- Loading states visible on initial page load
- All data fetched after React hydration
- Server-side prefetching with streaming
- Instant data display on page load (when authenticated)
- Seamless hydration with TanStack Query
Testing/Verification
Section titled “Testing/Verification”# Run type checkingturbo check-types --filter=web
# Run lintingturbo lint --filter=web
# Run teststurbo test --filter=web
# Start dev server to testturbo dev --filter=webExpected results:
- Page loads with data pre-populated (for authenticated users)
- No loading flicker on initial render
- Real-time updates continue to work after hydration