Just-Use-Convex Docs
Convex Fundamentals

Queries & Mutations

Learn how to read and write data with Convex queries and mutations

Queries & Mutations

Queries and mutations are the primary way to interact with your Convex database. This template enhances them with Zod validation and automatic auth context injection.

Query & Mutation Variants

This template defines three variants for each operation type:

VariantAuth MethodUse Case
zQuery / zMutationJWT (user session)Frontend client calls
zInternalQuery / zInternalMutationIdentity passed as argsServer-to-server (internal functions, triggers)
zExternalQuery / zExternalMutationexternalToken in argsAgent-side access (Cloudflare Workers, external services)

Queries

Queries read data from the database. They are:

  • Cached - Results are cached until underlying data changes
  • Reactive - Frontend subscriptions automatically update
  • Read-only - Cannot modify database state

Basic Query

import { zQuery } from "../functions";
import { z } from "zod";

export const list = zQuery({
  args: z.object({}),
  handler: async (ctx) => {
    return await ctx.table("todos").collect();
  },
});

Query with Arguments

export const getById = zQuery({
  args: z.object({
    id: z.string(),
  }),
  handler: async (ctx, args) => {
    return await ctx.table("todos").get(args.id as Id<"todos">);
  },
});

Query with Filters

export const listByStatus = zQuery({
  args: z.object({
    status: z.enum(["todo", "in_progress", "done"]),
  }),
  handler: async (ctx, args) => {
    return await ctx.table("todos")
      .filter((q) => q.eq(q.field("status"), args.status))
      .collect();
  },
});

Using Indexes

Always use indexes for filtered queries to ensure performance:

export const listByOrganization = zQuery({
  args: z.object({}),
  handler: async (ctx) => {
    // Uses the "organizationId" index
    return await ctx.table("todos", "organizationId", (q) =>
      q.eq("organizationId", ctx.identity.activeOrganizationId)
    ).collect();
  },
});

Using Search Indexes

For full-text search, use the search index defined on the table:

// chats/index.ts
export const search = zQuery({
  args: z.object({
    query: z.string(),
  }),
  handler: async (ctx, args) => {
    return await ctx.db
      .query("chats")
      .withSearchIndex("title", (q) =>
        q.search("title", args.query)
          .eq("organizationId", ctx.identity.activeOrganizationId)
          .eq("memberId", ctx.identity.memberId)
      )
      .collect();
  },
});

Mutations

Mutations modify data. They are:

  • Transactional - All changes succeed or all fail
  • Trigger Re-evaluation - Cause dependent queries to update
  • Write-capable - Can insert, update, and delete records

Basic Mutation

import { zMutation } from "../functions";
import { z } from "zod";

export const create = zMutation({
  args: z.object({
    title: z.string().min(1),
    priority: z.enum(["low", "medium", "high"]).default("medium"),
  }),
  handler: async (ctx, args) => {
    return await ctx.table("todos").insert({
      title: args.title,
      priority: args.priority,
      status: "todo",
      organizationId: ctx.identity.activeOrganizationId,
      memberId: ctx.identity.memberId,
      updatedAt: Date.now(),
    });
  },
});

Chat Create Mutation

export const create = zMutation({
  args: z.object({
    title: z.string().min(1),
  }),
  handler: async (ctx, args) => {
    return await ctx.table("chats").insert({
      title: args.title,
      organizationId: ctx.identity.activeOrganizationId,
      memberId: ctx.identity.memberId,
      isPinned: false,
      updatedAt: Date.now(),
    });
  },
});

Update Mutation

export const update = zMutation({
  args: z.object({
    id: z.string(),
    title: z.string().optional(),
    status: z.enum(["todo", "in_progress", "done"]).optional(),
    priority: z.enum(["low", "medium", "high"]).optional(),
  }),
  handler: async (ctx, args) => {
    const { id, ...updates } = args;

    const todo = await ctx.table("todos").get(id as Id<"todos">);
    if (!todo) throw new Error("Todo not found");

    // Verify ownership
    if (todo.organizationId !== ctx.identity.activeOrganizationId) {
      throw new Error("Unauthorized");
    }

    return await ctx.table("todos").patch(id as Id<"todos">, {
      ...updates,
      updatedAt: Date.now(),
    });
  },
});

Delete Mutation

export const remove = zMutation({
  args: z.object({
    id: z.string(),
  }),
  handler: async (ctx, args) => {
    const todo = await ctx.table("todos").get(args.id as Id<"todos">);
    if (!todo) throw new Error("Todo not found");

    if (todo.organizationId !== ctx.identity.activeOrganizationId) {
      throw new Error("Unauthorized");
    }

    await ctx.table("todos").delete(args.id as Id<"todos">);
  },
});

External Queries & Mutations

For agent-side access (e.g., from Cloudflare Workers), use zExternalQuery and zExternalMutation. These validate an externalToken from args instead of a JWT session:

import { zExternalQuery, zExternalMutation } from "../functions";

// External query: validates externalToken from args
export const getExt = zExternalQuery({
  args: z.object({
    chatId: z.string(),
  }),
  handler: async (ctx, args) => {
    return await ctx.table("chats").get(args.chatId as Id<"chats">);
  },
});

// External mutation: same token validation, write access
export const updateExt = zExternalMutation({
  args: z.object({
    chatId: z.string(),
    title: z.string().optional(),
  }),
  handler: async (ctx, args) => {
    return await ctx.table("chats").patch(args.chatId as Id<"chats">, {
      ...(args.title && { title: args.title }),
      updatedAt: Date.now(),
    });
  },
});

The external wrappers automatically inject externalToken plus the full identity fields (userId, activeOrganizationId, memberId, organizationRole, activeTeamId) into args. The caller must provide all of these. Inside the handler, ctx.identity works the same as zQuery/zMutation.

When to Use External vs Standard

ScenarioUse
Frontend React componentzQuery / zMutation
Convex internal function calling anotherzInternalQuery / zInternalMutation
Cloudflare Worker / AI agent calling ConvexzExternalQuery / zExternalMutation

Context Object

The ctx object in handlers provides:

PropertyDescription
ctx.table(name)Access a table with Ents API
ctx.dbRaw Convex database access
ctx.identityCurrent user's auth context (parsed from JWT or external args)
ctx.authAuth utilities

Identity Context

When using zQuery/zMutation, the identity is automatically parsed from the JWT. When using zExternalQuery/zExternalMutation, it is parsed from the args after token validation. Either way, the shape is the same:

ctx.identity = {
  userId: "user_123",              // Better Auth user ID
  activeOrganizationId: "org_456", // Current organization
  memberId: "member_789",          // Member ID within the org
  organizationRole: "member",      // User's role in org
  activeTeamId: "team_012",        // Current team (optional)
};

Best Practices

1. Always Validate Ownership

// Check org membership before operations
if (todo.organizationId !== ctx.identity.activeOrganizationId) {
  throw new Error("Unauthorized");
}

2. Use Indexes for Filters

// Good: Uses index
ctx.table("todos", "organizationId", (q) =>
  q.eq("organizationId", orgId)
);

// Avoid: Full table scan
ctx.table("todos").filter((q) =>
  q.eq(q.field("organizationId"), orgId)
);

3. Return Minimal Data

// Return only what the frontend needs
return {
  id: todo._id,
  title: todo.title,
  status: todo.status,
};

4. Handle Errors Gracefully

const todo = await ctx.table("todos").get(args.id);
if (!todo) {
  throw new Error("Todo not found");
}

5. Delegate to Pure Functions

Keep handlers thin by delegating to pure business logic functions:

// chats/index.ts
import * as functions from "./functions";
import * as types from "./types";

export const list = zQuery({
  args: types.ListArgs,
  handler: async (ctx, args) => {
    return await functions.ListChats(ctx, args);
  },
});

export const getExt = zExternalQuery({
  args: types.GetChatArgs,
  handler: async (ctx, args) => {
    return await functions.GetChat(ctx, args);
  },
});

This keeps the index file as a thin routing layer while business logic lives in functions.ts.

Next, learn about Schema Design.