BETTER-CONVEX

Context

Access database, auth, and custom data in your procedures.

In this guide, we'll explore the context object - the gateway to everything your procedures need. You'll learn about base context properties, how to extend context with middleware, and how actions differ from queries and mutations.

Overview

Context (ctx) is passed to every procedure handler. It provides access to Convex features and any data added by middleware.

PropertyAvailable InPurpose
ctx.ormQueries, MutationsDatabase access (the ORM)
ctx.authAllAuthentication info
ctx.storageAllFile storage
ctx.schedulerMutationsSchedule functions
ctx.runQuery / ctx.runMutationActionsCall other procedures

Let's explore each one.

Base Context

Every procedure receives the base Convex context. Here's what you get out of the box:

export const list = publicQuery
  .query(async ({ ctx }) => {
    // ctx.orm - Database access (the ORM)
    // ctx.auth - Authentication info
    // ctx.storage - File storage
    return ctx.orm.query.user.findMany({ limit: 100 });
  });

Database (ctx.orm)

The database is your primary way to read and write data. In queries, you can only read. In mutations, you can read and write.

import { user } from './schema';

// Read
const one = await ctx.orm.query.user.findFirst({ where: { id: id } });
const many = await ctx.orm.query.user.findMany({ limit: 100 });

// Write (mutations only)
await ctx.orm.insert(user).values({ name: 'John', email: 'john@example.com' });

Authentication (ctx.auth)

Access the authenticated user's identity. This is the raw identity from your auth provider - use middleware to transform it into your app's user object.

const identity = await ctx.auth.getUserIdentity();
if (!identity) {
  throw new CRPCError({ code: 'UNAUTHORIZED' });
}
// identity.subject - User ID from auth provider
// identity.email - Email (if available)
// identity.name - Name (if available)

Tip: Rather than calling getUserIdentity() in every procedure, create an authQuery middleware that adds ctx.user automatically. See the next section.

Extending Context with Middleware

Use middleware to add custom data to context - the authenticated user, feature flags, rate limit info, anything your procedures need.

The pattern is simple: fetch what you need, then call next() with the extended context.

convex/lib/crpc.ts
import { getSession } from 'kitcn/auth';

export const authQuery = c.query.use(async ({ ctx, next }) => {
  const session = await getSession(ctx);
  if (!session) {
    throw new CRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
  }

  const user = await ctx.orm.query.user.findFirst({
    where: { id: session.userId },
  });
  if (!user) throw new CRPCError({ code: 'UNAUTHORIZED' });

  return next({
    ctx: { ...ctx, user: { id: user.id, session, ...user }, userId: user.id },
  });
});

// Create mutation version with same pattern
export const authMutation = c.mutation.use(async ({ ctx, next }) => {
  // ... same auth logic
});

Now when you use authQuery or authMutation, ctx.user and ctx.userId are guaranteed to exist:

convex/functions/session.ts
import { z } from 'zod';
import { authMutation } from '../lib/crpc';
import { session } from './schema';

export const create = authMutation
  .input(z.object({ token: z.string() }))
  .output(z.string())
  .mutation(async ({ ctx, input }) => {
    // ctx.user and ctx.userId are now available and typed!
    const [row] = await ctx.orm
      .insert(session)
      .values({
        ...input,
        userId: ctx.userId,
        expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
      })
      .returning({ id: session.id });
    return row.id;
  });

Context in Actions

Actions have a different context. Instead of direct database access, they call other procedures. Use the module runtime caller (for example createUserCaller(ctx)) for type-safe dispatch with full autocomplete:

import { createUserCaller } from './generated/user.runtime';

export const processAndSave = publicAction
  .input(z.object({ data: z.string() }))
  .action(async ({ ctx, input }) => {
    // External API call (only possible in actions)
    const result = await fetch('https://api.example.com/process', {
      method: 'POST',
      body: JSON.stringify({ data: input.data }),
    });

    // Call a mutation to save results
    const caller = createUserCaller(ctx);
    await caller.updateProfile({ data: await result.json() });
    await caller.actions.syncExternalAnalytics({ userId: 'u_1' });
    await caller.schedule.now.sendFollowUpEmail({ userId: 'u_1' });
  });

Note: Actions can't access the database directly. Module runtime callers (like createUserCaller(ctx)) dispatch root calls via ctx.runQuery / ctx.runMutation, action calls via caller.actions.* (which uses ctx.runAction), and scheduling via caller.schedule.* (which uses ctx.scheduler).

Migrate from Convex

If you're coming from vanilla Convex or convex-helpers, here's what changes.

What stays the same

Base context properties work identically:

  • ctx.orm - Database access (queries + mutations)
  • ctx.auth - Authentication
  • ctx.storage - File storage
  • ctx.scheduler - Scheduler (mutations + actions)
  • ctx.runQuery / ctx.runMutation / ctx.runAction - Call procedures (actions only)

What's new

cRPC's .use() middleware replaces convex-helpers' customQuery/customMutation:

Before (convex-helpers)
import { customQuery, customCtx } from 'convex-helpers/server/customFunctions';

const userQuery = customQuery(query,
  customCtx(async (ctx) => {
    const user = await getUser(ctx);
    if (!user) throw new Error('Authentication required');
    return { user };
  })
);
After (cRPC)
const userQuery = c.query.use(async ({ ctx, next }) => {
  const user = await getUser(ctx);
  if (!user) throw new CRPCError({ code: 'UNAUTHORIZED' });
  return next({ ctx: { ...ctx, user } });
});

Key differences:

  • Call next({ ctx }) instead of returning context directly
  • Use CRPCError for typed errors
  • Chain multiple .use() calls for composable middleware

Next Steps

On this page