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.db / ctx.tableQueries, MutationsDatabase access
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.db - Database access
    // ctx.auth - Authentication info
    // ctx.storage - File storage
    return ctx.db.query('user').collect();
  });
export const list = publicQuery
  .query(async ({ ctx }) => {
    // ctx.table - Database access (Convex Ents)
    // ctx.auth - Authentication info
    // ctx.storage - File storage
    return ctx.table('user');
  });

Database (ctx.db / ctx.table)

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

// Read
const user = await ctx.db.get(id);
const users = await ctx.db.query('user').collect();

// Write (mutations only)
const id = await ctx.db.insert('user', { name: 'John', email: 'john@example.com' });
await ctx.db.patch(id, { name: 'Updated' });
await ctx.db.delete(id);
// Read
const user = await ctx.table('user').get(id);
const users = await ctx.table('user');

// Write (mutations only)
const id = await ctx.table('user').insert({ name: 'John', email: 'john@example.com' });
await ctx.table('user').getX(id).patch({ name: 'Updated' });
await ctx.table('user').getX(id).delete();

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

Here's where cRPC shines. 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 'better-convex/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.db.get(session.userId))!;

  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
});
convex/lib/crpc.ts
import { getSession } from 'better-convex/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.table('user').getX(session.userId);

  return next({
    ctx: { ...ctx, user: { id: user._id, session, ...user.doc() }, 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
export const create = authMutation
  .input(z.object({ token: z.string() }))
  .mutation(async ({ ctx, input }) => {
    // ctx.user and ctx.userId are now available and typed!
    return ctx.db.insert('session', {
      ...input,
      userId: ctx.userId,
      expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
    });
  });
convex/functions/session.ts
export const create = authMutation
  .input(z.object({ token: z.string() }))
  .mutation(async ({ ctx, input }) => {
    // ctx.user and ctx.userId are now available and typed!
    return ctx.table('session').insert({
      ...input,
      userId: ctx.userId,
      expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
    });
  });

Context in Actions

Actions have a different context. Instead of direct database access, they use runQuery and runMutation to call other procedures.

import { internal } from './_generated/api';

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
    await ctx.runMutation(internal.user.updateProfile, {
      data: await result.json()
    });
  });

Note: Actions can't access ctx.db directly. Use ctx.runQuery to read data and ctx.runMutation to write data. This ensures all database operations are transactional.

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.db - Database reader/writer
  • ctx.auth - Authentication
  • ctx.storage - File storage
  • ctx.scheduler - Scheduler (mutations 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