BETTER-CONVEX

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.

components/todo-list.tsx
'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 queryOptions and mutationOptions.
  • RSC prefetching with prefetch() and preloadQuery().
Use CaseApproach
REST APIs (typed)cRPC HTTP Router ✅
Browser/ReactcRPC HTTP + TanStack Query ✅
Next.js RSCcRPC HTTP + prefetch ✅
Webhooks (Stripe, GitHub)Traditional HTTP
External integrationsTraditional HTTP

Anatomy

Here's a basic example of an HTTP endpoint using cRPC with the fluent builder API.

convex/routers/health.ts
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:

  1. Route builder (publicRoute or authRoute) - determines authentication requirements
  2. HTTP method (.get(), .post(), .patch(), .delete()) - sets the method and path
  3. Validation (.input(), .params(), .searchParams(), .output()) - Zod schemas
  4. 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.

convex/lib/crpc.ts
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

BuilderAuthenticationUse Case
publicRouteNonePublic APIs, health checks
authRouteRequiredUser-specific operations
Custom middlewareConditionalRole-based access, API keys

Defining Routes

GET Endpoints

Use .get() with .query() for read operations. GET endpoints cannot have a request body.

convex/routers/health.ts
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.

convex/routers/todos.ts
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.

convex/routers/todos.ts
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.

convex/routers/todos.ts
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.

convex/routers/todos.ts
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.

convex/routers/todos.ts
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

MethodBuilderUse CaseHas Body
GET.get().query()Read operationsNo
POST.post().mutation()Create operationsYes
PATCH.patch().mutation()Partial updatesYes
DELETE.delete().mutation()Delete operationsNo

Routers

Group related endpoints into routers for better organization and code splitting.

Creating a Router

convex/routers/todos.ts
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.

convex/functions/http.ts
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 hono
convex/functions/http.ts
import '../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

ComponentPurpose
HonoRoute 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.

convex/functions/http.ts
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:

OptionTypeDefaultDescription
originstring | string[]'*'Allowed origin URLs or '*' for any
allowMethodsstring[]['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH']Allowed HTTP methods
allowHeadersstring[][]Allowed request headers
exposeHeadersstring[][]Headers exposed to the browser
credentialsbooleanfalseAllow cookies and auth headers
maxAgenumber-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.

convex/routers/todos.ts
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

CodeHTTP StatusUse Case
BAD_REQUEST400Invalid request format
UNAUTHORIZED401Missing or invalid authentication
FORBIDDEN403Authenticated but not authorized
NOT_FOUND404Resource doesn't exist
CONFLICT409Resource conflict (e.g., duplicate)
UNPROCESSABLE_CONTENT422Validation failed
TOO_MANY_REQUESTS429Rate limit exceeded
INTERNAL_SERVER_ERROR500Unexpected 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

convex/routers/todos.ts
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

convex/routers/public.ts
export const redirect = publicRoute
  .get('/api/old-path')
  .query(async ({ c }) => c.redirect('/api/new-path', 301));

Hono Context Methods

MethodDescription
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

convex/routers/stream.ts
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

convex/routers/ai.ts
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.

convex/routers/webhooks.ts
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):

PropertyTypeDescription
paramsRecord<string, string>Path parameters (:id in /users/:id)
searchParamsRecord<string, string | string[]>Query string parameters
formz.infer<TForm>Typed FormData (if server uses .form())
fetchtypeof fetchCustom fetch function (per-call override)
initRequestInitStandard fetch options (highest priority)
headersRecord<string, string> | (() => ...)Request headers (incl. auth, cookies)
[key]unknownJSON 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.

lib/convex/crpc.tsx
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.

components/todo-list.tsx
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

MethodContextCachingUse Case
client.http.*.query()AnywhereNoneDirect calls without cache
crpc.http.*.queryOptions()Render onlyUses cacheComponents (uses hooks)
crpc.http.*.staticQueryOptions()AnywhereUses cachePrefetching, event handlers

When to use which:

  • Use queryOptions in components for reactive auth handling
  • Use staticQueryOptions for 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.

components/create-todo.tsx
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:

convex/routers/files.ts
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

MethodSignatureDescription
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.

Note: For more React patterns including loading states, error handling, and conditional queries, see Queries and Mutations.

RSC Prefetching

Prefetch HTTP endpoints in Server Components for instant client hydration.

Basic Prefetch

Use prefetch() for fire-and-forget data loading.

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

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

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

PatternBlockingServer AccessClient Hydration
prefetch()NoNoYes
preloadQuery()YesYesYes

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.

app/api/sync/route.ts
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.

Next Steps

On this page