HTTP Router
Build typed REST APIs with cRPC HTTP router, webhooks, and external integrations.
This guide explores how to build HTTP endpoints using cRPC HTTP router. You'll learn to create typed REST APIs with the fluent builder API, implement schema validation with Zod, handle CORS, and integrate with TanStack Query on the client.
Demo
We'll build a todo API with full CRUD operations. The API has typed endpoints for listing, creating, updating, and deleting todos. On the client, we use TanStack Query for data fetching and cache invalidation.
Note: For real-time subscriptions, use queries and mutations instead - they're reactive and handle caching automatically via WebSocket. HTTP endpoints are best for REST APIs consumed by external clients, or when you need standard HTTP semantics.
'use client';
import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
export function TodoList() {
const crpc = useCRPC();
const queryClient = useQueryClient();
// GET /api/todos?limit=10
const { data: todos } = useSuspenseQuery(
crpc.http.todos.list.queryOptions({ searchParams: { limit: '10' } })
);
// POST /api/todos → useMutation
// mutationOptions(opts?) - merged TanStack + client options
const createTodo = useMutation(
crpc.http.todos.create.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries(crpc.http.todos.list.queryFilter());
},
})
);
return (
<div>
{todos.map(todo => (
<div key={todo._id}>{todo.title}</div>
))}
{/* JSON body at root level (tRPC-style) */}
<button onClick={() => createTodo.mutate({ title: 'New Todo' })}>
Add
</button>
</div>
);
}Approach
The cRPC HTTP router provides a tRPC-style fluent API for building typed HTTP endpoints. This approach gives you:
- End-to-end type safety from server to client via TypeScript inference.
- Zod validation for request body, path params, and search params.
- Automatic OpenAPI-style routing with path parameters (
:id). - TanStack Query integration via
queryOptionsandmutationOptions. - RSC prefetching with
prefetch()andpreloadQuery().
| Use Case | Approach |
|---|---|
| REST APIs (typed) | cRPC HTTP Router ✅ |
| Browser/React | cRPC HTTP + TanStack Query ✅ |
| Next.js RSC | cRPC HTTP + prefetch ✅ |
| Webhooks (Stripe, GitHub) | Traditional HTTP |
| External integrations | Traditional HTTP |
Anatomy
Here's a basic example of an HTTP endpoint using cRPC with the fluent builder API.
import { z } from 'zod';
import { publicRoute } from '../lib/crpc';
export const health = publicRoute
.get('/api/health') // HTTP method + path
.output(z.object({ status: z.string(), timestamp: z.number() })) // Response schema
.query(async () => ({ // Handler
status: 'ok',
timestamp: Date.now(),
}));The fluent API chains methods to build the endpoint:
- Route builder (
publicRouteorauthRoute) - determines authentication requirements - HTTP method (
.get(),.post(),.patch(),.delete()) - sets the method and path - Validation (
.input(),.params(),.searchParams(),.output()) - Zod schemas - Handler (
.query()or.mutation()) - the function that processes the request
Setup
Create Route Builders
Export HTTP route builders from your cRPC setup. These determine whether endpoints require authentication.
import { CRPCError, initCRPC } from 'better-convex/server';
import type { DataModel, Id } from '../_generated/dataModel';
import { httpAction } from '../_generated/server';
const c = initCRPC
.dataModel<DataModel>()
.context({ /* your context setup */ })
.create({ /* your options */, httpAction });
// Public HTTP route - no auth required
export const publicRoute = c.httpAction;
// Auth HTTP route - verifies JWT and adds userId to context
export const authRoute = c.httpAction.use(async ({ ctx, next }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new CRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: { ...ctx, userId: identity.subject as Id<'user'> },
});
});
// Router factory for grouping endpoints
export const router = c.router;Route Builder Options
| Builder | Authentication | Use Case |
|---|---|---|
publicRoute | None | Public APIs, health checks |
authRoute | Required | User-specific operations |
| Custom middleware | Conditional | Role-based access, API keys |
Defining Routes
GET Endpoints
Use .get() with .query() for read operations. GET endpoints cannot have a request body.
import { z } from 'zod';
import { publicRoute } from '../lib/crpc';
export const health = publicRoute
.get('/api/health')
.output(z.object({
status: z.string(),
timestamp: z.number(),
}))
.query(async () => ({
status: 'ok',
timestamp: Date.now(),
}));Search Parameters
Use .searchParams() for query string validation. Values come as strings, so use z.coerce for type conversion.
import { z } from 'zod';
import { publicRoute } from '../lib/crpc';
export const list = publicRoute
.get('/api/todos')
.searchParams(z.object({
limit: z.coerce.number().optional().default(10),
offset: z.coerce.number().optional().default(0),
}))
.output(z.array(todoSchema))
.query(async ({ ctx, searchParams }) => {
// searchParams.limit and searchParams.offset are typed as numbers
return ctx.runQuery(api.todos.list, {
limit: searchParams.limit,
offset: searchParams.offset,
});
});Note: Use z.coerce.number() instead of z.number() for search params since URL query strings are always strings. The coerce transformer handles the conversion.
Path Parameters
Use .params() for URL path parameters. Define placeholders with :paramName in the path.
import { z } from 'zod';
import { zid } from 'convex-helpers/server/zod4';
import { publicRoute } from '../lib/crpc';
export const get = publicRoute
.get('/api/todos/:id')
.params(z.object({ id: zid('todos') }))
.output(todoSchema.nullable())
.query(async ({ ctx, params }) => {
// params.id is typed as Id<'todos'>
return ctx.runQuery(api.todos.get, { id: params.id });
});POST Endpoints
Use .post() with .input() and .mutation() for create operations.
import { z } from 'zod';
import { zid } from 'convex-helpers/server/zod4';
import { authRoute } from '../lib/crpc';
export const create = authRoute
.post('/api/todos')
.input(z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
}))
.output(z.object({ id: zid('todos') }))
.mutation(async ({ ctx, input }) => {
// input.title and input.description are validated
const id = await ctx.runMutation(internal.todos.create, {
userId: ctx.userId, // From auth middleware
...input,
});
return { id };
});PATCH Endpoints
Use .patch() for partial updates. Combine .params() for the resource ID and .input() for the update data.
import { z } from 'zod';
import { zid } from 'convex-helpers/server/zod4';
import { authRoute } from '../lib/crpc';
export const update = authRoute
.patch('/api/todos/:id')
.params(z.object({ id: zid('todos') }))
.input(z.object({
title: z.string().optional(),
completed: z.boolean().optional(),
}))
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, params, input }) => {
// params.id is the resource, input contains update fields
await ctx.runMutation(internal.todos.update, {
id: params.id,
...input,
});
return { success: true };
});DELETE Endpoints
Use .delete() for resource deletion. Typically only needs .params() for the resource ID.
import { z } from 'zod';
import { zid } from 'convex-helpers/server/zod4';
import { authRoute } from '../lib/crpc';
export const deleteTodo = authRoute
.delete('/api/todos/:id')
.params(z.object({ id: zid('todos') }))
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, params }) => {
await ctx.runMutation(internal.todos.delete, { id: params.id });
return { success: true };
});HTTP Method Summary
| Method | Builder | Use Case | Has Body |
|---|---|---|---|
| GET | .get().query() | Read operations | No |
| POST | .post().mutation() | Create operations | Yes |
| PATCH | .patch().mutation() | Partial updates | Yes |
| DELETE | .delete().mutation() | Delete operations | No |
Routers
Group related endpoints into routers for better organization and code splitting.
Creating a Router
import { z } from 'zod';
import { zid } from 'convex-helpers/server/zod4';
import { router, publicRoute, authRoute } from '../lib/crpc';
const todoSchema = z.object({
_id: zid('todos'),
title: z.string(),
completed: z.boolean(),
description: z.string().optional(),
});
const list = publicRoute.get('/api/todos')/* ... */;
const get = publicRoute.get('/api/todos/:id')/* ... */;
const create = authRoute.post('/api/todos')/* ... */;
const update = authRoute.patch('/api/todos/:id')/* ... */;
const deleteTodo = authRoute.delete('/api/todos/:id')/* ... */;
export const todosRouter = router({
list,
get,
create,
update,
delete: deleteTodo,
});Combining Routers
Compose routers into a single app router for registration.
import { router } from '../lib/crpc';
import { health } from '../routers/health';
import { todosRouter } from '../routers/todos';
import { usersRouter } from '../routers/users';
// Combine all routes into app router
export const appRouter = router({
health,
todos: todosRouter,
users: usersRouter,
});Registration
The HTTP router uses Hono for routing and middleware. This provides CORS handling, middleware chains, and a clean API for building HTTP endpoints.
bun add honoimport '../lib/http-polyfills';
import { authMiddleware } from 'better-convex/auth';
import { createHttpRouter } from 'better-convex/server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { router } from '../lib/crpc';
import { createAuth } from './auth';
import { health } from '../routers/health';
import { todosRouter } from '../routers/todos';
const app = new Hono();
// CORS for API routes (auth + cRPC)
app.use(
'/api/*',
cors({
origin: process.env.SITE_URL!,
allowHeaders: ['Content-Type', 'Authorization', 'Better-Auth-Cookie'],
exposeHeaders: ['Set-Better-Auth-Cookie'],
credentials: true,
})
);
// Better Auth middleware
app.use(authMiddleware(createAuth));
// cRPC router
export const appRouter = router({
health,
todos: todosRouter,
});
export default createHttpRouter(app, appRouter);Key Components
| Component | Purpose |
|---|---|
Hono | Route handling, middleware, CORS |
authMiddleware(createAuth) | Better Auth routes middleware |
createHttpRouter(app, appRouter) | Creates Convex HttpRouter with Hono + cRPC |
CORS
Configure Cross-Origin Resource Sharing using Hono's cors() middleware.
import { cors } from 'hono/cors';
const app = new Hono();
app.use(
'/api/*',
cors({
origin: ['https://myapp.com', 'http://localhost:3000'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true,
})
);CORS Options
Hono's CORS middleware accepts these options:
| Option | Type | Default | Description |
|---|---|---|---|
origin | string | string[] | '*' | Allowed origin URLs or '*' for any |
allowMethods | string[] | ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH'] | Allowed HTTP methods |
allowHeaders | string[] | [] | Allowed request headers |
exposeHeaders | string[] | [] | Headers exposed to the browser |
credentials | boolean | false | Allow cookies and auth headers |
maxAge | number | - | Preflight cache duration in seconds |
Note: For Better Auth integration, include 'Better-Auth-Cookie' in allowHeaders and 'Set-Better-Auth-Cookie' in exposeHeaders to ensure auth cookies work correctly.
Error Handling
Throw CRPCError to return appropriate HTTP status codes. The error code maps to standard HTTP status codes.
import { CRPCError } from 'better-convex/server';
export const get = publicRoute
.get('/api/todos/:id')
.params(z.object({ id: zid('todos') }))
.output(todoSchema)
.query(async ({ ctx, params }) => {
const todo = await ctx.runQuery(api.todos.get, { id: params.id });
if (!todo) {
throw new CRPCError({
code: 'NOT_FOUND',
message: 'Todo not found',
});
}
return todo;
});Error Code Reference
| Code | HTTP Status | Use Case |
|---|---|---|
BAD_REQUEST | 400 | Invalid request format |
UNAUTHORIZED | 401 | Missing or invalid authentication |
FORBIDDEN | 403 | Authenticated but not authorized |
NOT_FOUND | 404 | Resource doesn't exist |
CONFLICT | 409 | Resource conflict (e.g., duplicate) |
UNPROCESSABLE_CONTENT | 422 | Validation failed |
TOO_MANY_REQUESTS | 429 | Rate limit exceeded |
INTERNAL_SERVER_ERROR | 500 | Unexpected server error |
Validation Errors
Zod validation failures automatically return 400 Bad Request with error details.
{
"error": {
"code": "BAD_REQUEST",
"message": "Validation failed",
"details": [
{ "path": ["title"], "message": "String must contain at least 1 character(s)" }
]
}
}Custom Responses
cRPC handlers receive c (Hono Context) for custom responses, headers, and raw request access. This lets you return non-JSON responses like file downloads, redirects, or streaming data.
File Downloads
export const download = authRoute
.get('/api/todos/export/:format')
.params(z.object({ format: z.enum(['json', 'csv']) }))
.query(async ({ ctx, params, c }) => {
const todos = await ctx.runQuery(api.todos.list, { limit: 100 });
c.header('Content-Disposition', `attachment; filename="todos.${params.format}"`);
c.header('Cache-Control', 'no-cache');
if (params.format === 'csv') {
const csv = [
'id,title,completed',
...todos.map((t) => `${t._id},${t.title},${t.completed}`),
].join('\n');
return c.text(csv);
}
return c.json({ todos });
});Redirects
export const redirect = publicRoute
.get('/api/old-path')
.query(async ({ c }) => c.redirect('/api/new-path', 301));Hono Context Methods
| Method | Description |
|---|---|
c.json(data) | Return JSON response |
c.text(str) | Return text response |
c.redirect(url, status?) | Return redirect response |
c.header(name, value) | Set response header |
c.req.header(name) | Get request header |
c.req.text() | Get raw request body as text |
Streaming
For Server-Sent Events (SSE) and AI streaming, use Hono's streaming helpers.
Server-Sent Events
import { streamText } from 'hono/streaming';
export const events = publicRoute
.get('/api/stream')
.query(async ({ ctx, c }) => {
c.header('Content-Type', 'text/event-stream');
c.header('Cache-Control', 'no-cache');
c.header('Connection', 'keep-alive');
return streamText(c, async (stream) => {
for (let i = 0; i < 10; i++) {
const data = await ctx.runQuery(internal.data.getChunk, { index: i });
await stream.write(`data: ${JSON.stringify(data)}\n\n`);
await stream.sleep(1000);
}
});
});AI Streaming
import { stream } from 'hono/streaming';
export const chat = publicRoute
.post('/api/ai/stream')
.input(z.object({ prompt: z.string() }))
.mutation(async ({ ctx, input, c }) => {
c.header('Content-Type', 'text/event-stream');
c.header('Cache-Control', 'no-cache');
const aiStream = await ctx.runAction(internal.ai.streamResponse, {
prompt: input.prompt,
});
return stream(c, async (stream) => {
await stream.pipe(aiStream);
});
});Webhooks
Handle external service callbacks like Stripe or GitHub using cRPC routes. Always verify signatures before processing.
import { CRPCError } from 'better-convex/server';
import { publicRoute } from '../lib/crpc';
export const stripe = publicRoute
.post('/webhooks/stripe')
.mutation(async ({ ctx, c }) => {
// 1. Check signature header
const signature = c.req.header('stripe-signature');
if (!signature) {
throw new CRPCError({ code: 'BAD_REQUEST', message: 'No signature' });
}
const body = await c.req.text();
// 2. Verify signature
const isValid = await ctx.runAction(internal.stripe.verify, {
body,
signature,
});
if (!isValid) {
throw new CRPCError({ code: 'BAD_REQUEST', message: 'Invalid signature' });
}
// 3. Process the webhook
const event = JSON.parse(body);
switch (event.type) {
case 'payment_intent.succeeded':
await ctx.runMutation(internal.payments.markPaid, {
paymentIntentId: event.data.object.id,
});
break;
case 'customer.subscription.deleted':
await ctx.runMutation(internal.subscriptions.cancel, {
subscriptionId: event.data.object.id,
});
break;
}
return c.text('OK', 200);
});React Client
Access HTTP endpoints on the client via crpc.http.*. The proxy uses a hybrid API that combines the best of both worlds: tRPC-style JSON body at root level with explicit params/searchParams/form for URL and form data.
Input Args
The client uses a structured input format that separates concerns clearly. All options go in args (1st param):
| Property | Type | Description |
|---|---|---|
params | Record<string, string> | Path parameters (:id in /users/:id) |
searchParams | Record<string, string | string[]> | Query string parameters |
form | z.infer<TForm> | Typed FormData (if server uses .form()) |
fetch | typeof fetch | Custom fetch function (per-call override) |
init | RequestInit | Standard fetch options (highest priority) |
headers | Record<string, string> | (() => ...) | Request headers (incl. auth, cookies) |
[key] | unknown | JSON body fields at root level |
JSON body fields are spread at the root level (tRPC-style), while URL-related data and client options use explicit keys.
Setup
The HTTP proxy is automatically available when you set up cRPC.
import { api } from '@convex/api';
import { meta } from '@convex/meta';
import type { Api } from '@convex/types';
import { createCRPCContext } from 'better-convex/react';
export const { CRPCProvider, useCRPC } = createCRPCContext<Api>({
api,
meta,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!, // Required for HTTP
});GET Requests
Use queryOptions with TanStack Query's useQuery or useSuspenseQuery.
import { useSuspenseQuery } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
export function TodoList() {
const crpc = useCRPC();
// GET /api/todos?limit=10 - searchParams explicit
const { data: todos } = useSuspenseQuery(
crpc.http.todos.list.queryOptions({ searchParams: { limit: '10' } })
);
// GET /api/todos/:id - path params explicit
const { data: todo } = useSuspenseQuery(
crpc.http.todos.get.queryOptions({ params: { id: todoId } })
);
return (
<ul>
{todos.map(todo => (
<li key={todo._id}>{todo.title}</li>
))}
</ul>
);
}One-Time Fetch
For one-time fetches like exports or downloads, use mutationOptions with useMutation. This gives you isPending state and onSuccess/onError callbacks without caching:
const exportTodos = useMutation(
crpc.http.todos.export.mutationOptions()
);
// Trigger export with loading state
exportTodos.mutate({ params: { format: 'csv' } });For imperative fetching in event handlers or effects, use the vanilla client:
import { useQueryClient } from '@tanstack/react-query';
import { useCRPC, useCRPCClient } from '@/lib/convex/crpc';
const client = useCRPCClient();
const todos = await client.http.todos.list.query();
await client.http.todos.create.mutate({ title: 'New todo' });
// For cache-aware fetches in render context
const crpc = useCRPC();
const queryClient = useQueryClient();
const todos = await queryClient.fetchQuery(crpc.http.todos.list.queryOptions());staticQueryOptions
For prefetching in event handlers, use staticQueryOptions. Unlike queryOptions, it doesn't use hooks internally, so you can call it anywhere:
import { useQueryClient } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
const crpc = useCRPC();
const queryClient = useQueryClient();
// Prefetch on hover - works in event handlers
const handleMouseEnter = () => {
queryClient.prefetchQuery(crpc.http.todos.list.staticQueryOptions());
};
// Fetch on click
const handleClick = async () => {
const todos = await queryClient.fetchQuery(
crpc.http.todos.list.staticQueryOptions()
);
};Note: staticQueryOptions doesn't include reactive auth state. Auth is handled at execution time by the queryFn - if the user isn't authenticated for a protected endpoint, it will throw an UNAUTHORIZED error.
Comparison
| Method | Context | Caching | Use Case |
|---|---|---|---|
client.http.*.query() | Anywhere | None | Direct calls without cache |
crpc.http.*.queryOptions() | Render only | Uses cache | Components (uses hooks) |
crpc.http.*.staticQueryOptions() | Anywhere | Uses cache | Prefetching, event handlers |
When to use which:
- Use
queryOptionsin components for reactive auth handling - Use
staticQueryOptionsfor prefetching in event handlers - Use
client.http.*.query()when you don't need TanStack Query caching
POST/PATCH/DELETE Requests
Use mutationOptions with TanStack Query's useMutation.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
export function CreateTodo() {
const crpc = useCRPC();
const queryClient = useQueryClient();
const [title, setTitle] = useState('');
// POST /api/todos
// mutationOptions(opts?) - merged TanStack + client options
const createTodo = useMutation(
crpc.http.todos.create.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries(crpc.http.todos.list.queryFilter());
setTitle('');
},
})
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// JSON body at root level (tRPC-style)
createTodo.mutate({ title });
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter todo title"
/>
<button type="submit" disabled={createTodo.isPending}>
{createTodo.isPending ? 'Creating...' : 'Add Todo'}
</button>
</form>
);
}File Uploads with FormData
For typed file uploads, define a .form() schema on your endpoint:
export const upload = authRoute
.post('/api/files/upload')
.form(z.object({
file: z.instanceof(File),
title: z.string().optional(),
tags: z.array(z.string()).optional(),
}))
.mutation(async ({ ctx, c, form }) => {
// form.file is typed as File, form.title as string | undefined
const storageId = await ctx.storage.store(form.file);
return c.json({ storageId, filename: form.file.name });
});The client then gets typed form args:
const uploadFile = useMutation(
crpc.http.files.upload.mutationOptions()
);
// Single file - typed from .form() schema
uploadFile.mutate({ form: { file: selectedFile } });
// Mixed form data - all fields typed
uploadFile.mutate({
form: {
title: 'My Document',
file: selectedFile,
tags: ['work', 'important'],
}
});Cache Invalidation
Use queryFilter() to invalidate related queries after mutations.
const crpc = useCRPC();
const queryClient = useQueryClient();
const deleteTodo = useMutation(
crpc.http.todos.delete.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries(crpc.http.todos.list.queryFilter());
},
})
);
const updateTodo = useMutation(
crpc.http.todos.update.mutationOptions({
onSuccess: (_, vars) => {
// vars is HttpInputArgs - access params.id
queryClient.invalidateQueries(crpc.http.todos.list.queryFilter());
queryClient.invalidateQueries(
crpc.http.todos.get.queryFilter({ params: { id: vars.params?.id } })
);
},
})
);
// PATCH with path params + JSON body at root
updateTodo.mutate({ params: { id: todoId }, completed: true });
// DELETE with path params
deleteTodo.mutate({ params: { id: todoId } });Custom Headers and Fetch Options
You can pass custom headers, a custom fetch function, or standard RequestInit options directly in your args. This keeps everything in one place and makes per-request customization straightforward.
For queries, add client options alongside your other args:
// Query with custom headers
const { data } = useSuspenseQuery(
crpc.http.todos.list.queryOptions({
searchParams: { limit: '10' },
headers: { 'X-Custom-Header': 'value' },
})
);For mutations, you can set options at the mutationOptions level (for defaults) or pass them per-call in mutate(). Per-call options are useful for things like auth tokens or abort signals:
const controller = new AbortController();
const createTodo = useMutation(
crpc.http.todos.create.mutationOptions({
onSuccess: () => console.log('Created!'),
})
);
// Per-call client options in mutate args
createTodo.mutate({
title: 'New Todo',
headers: { 'Authorization': `Bearer ${token}` },
init: { signal: controller.signal },
});Client API Reference
| Method | Signature | Description |
|---|---|---|
queryOptions | (args?, queryOpts?) | Options for useQuery/useSuspenseQuery. args includes client opts, queryOpts is TanStack Query only |
mutationOptions | (mutationOpts?) | Options for useMutation. mutationOpts is TanStack Mutation only, client opts go in mutate(args) |
queryKey | (args?) | Get the query key for cache operations |
queryFilter | (args?, filters?) | Filter for invalidateQueries, etc. |
RSC Prefetching
Prefetch HTTP endpoints in Server Components for instant client hydration.
Basic Prefetch
Use prefetch() for fire-and-forget data loading.
import { crpc, HydrateClient, prefetch } from '@/lib/convex/rsc';
import { TodoList } from './todo-list';
export default async function TodosPage() {
// Fire-and-forget prefetch - doesn't block rendering
prefetch(crpc.http.health.queryOptions());
prefetch(crpc.http.todos.list.queryOptions({ searchParams: { limit: '10' } }));
return (
<HydrateClient>
<TodoList />
</HydrateClient>
);
}Awaited Prefetch
Use preloadQuery() when you need the data on the server (e.g., for conditionals or metadata).
import { crpc, HydrateClient, preloadQuery } from '@/lib/convex/rsc';
import { notFound } from 'next/navigation';
import { TodoDetail } from './todo-detail';
export default async function TodoPage({
params,
}: { params: Promise<{ id: string }> }) {
const { id } = await params;
// Await the data - available on server
// Path params explicit: { params: { ... } }
const todo = await preloadQuery(
crpc.http.todos.get.queryOptions({ params: { id } })
);
if (!todo) {
notFound();
}
return (
<HydrateClient>
<TodoDetail />
</HydrateClient>
);
}Auth-Aware Prefetch
Use skipUnauth to skip auth-required queries without errors when not logged in.
import { crpc, HydrateClient, prefetch } from '@/lib/convex/rsc';
export default async function AppLayout({ children }: { children: React.ReactNode }) {
// Skip if not authenticated - returns null instead of erroring
prefetch(
crpc.http.todos.list.queryOptions(
{ searchParams: { limit: '10' } },
{ skipUnauth: true }
)
);
return <HydrateClient>{children}</HydrateClient>;
}RSC Pattern Comparison
| Pattern | Blocking | Server Access | Client Hydration |
|---|---|---|---|
prefetch() | No | No | Yes |
preloadQuery() | Yes | Yes | Yes |
Note: RSC HTTP prefetching requires convexSiteUrl in getServerQueryClientOptions. For complete setup including QueryClient configuration, see Next.js RSC.
Server-Side Calls
For server-to-server calls outside React (API routes, middleware, cron jobs), use createContext with the caller pattern. The caller accesses your cRPC router namespaces directly.
import { createContext } from '@/lib/convex/server';
export async function POST(request: Request) {
// Create context with auth headers
const ctx = await createContext({ headers: request.headers });
// Query using the caller (matches your appRouter structure)
const todos = await ctx.caller.todos.list({ limit: 10 });
// Mutations
if (ctx.isAuthenticated) {
await ctx.caller.todos.create({ title: 'New task' });
}
return Response.json({ todos });
}Note: The caller provides full type safety and uses your cRPC router definitions. See Server-Side Calls for complete setup.