Leave Localhost logoLeave LocalhostDocs
Architecture

Server Actions and Functions

All backend logic runs as Convex functions. This page explains the function types, patterns, and conventions used throughout the starter.

All backend logic runs as Convex functions. This page explains the function types, patterns, and conventions used throughout the starter.

Function Types

Queries

Queries read data and are reactive — clients subscribe and receive updates automatically whenever the underlying data changes.

The Workspace Records snippets below describe the removable demo. Follow Removing the Workspace Demo when replacing it with product-specific tenant data.

import { query } from "./_generated/server";

export const listRecords = query({
  args: {},
  handler: async (ctx) => {
    const actor = await requireAppPermission(ctx, {
      permission: "workspace.records.read",
    });
    return await ctx.db
      .query("workspace_records")
      .withIndex("by_organizationId", (q) =>
        q.eq("organizationId", actor.organizationId),
      )
      .collect();
  },
});

Mutations

Mutations write data within a transaction. If any read changes during execution, the mutation automatically retries.

import { mutation } from "./_generated/server";
import { v } from "convex/values";

export const createRecord = mutation({
  args: { title: v.string() },
  handler: async (ctx, args) => {
    const actor = await requireAppPermission(ctx, {
      permission: "workspace.records.create",
    });
    return await ctx.db.insert("workspace_records", {
      organizationId: actor.organizationId,
      title: args.title,
      // ...
    });
  },
});

Actions

Actions run side effects like external API calls. They cannot read or write the database directly — they call queries and mutations via ctx.runQuery() and ctx.runMutation().

import { action } from "./_generated/server";

export const createCheckout = action({
  args: { planKey: billingPlanKeyValidator },
  handler: async (ctx, args) => {
    const access = await ctx.runQuery(internal.billing.requireBillingAccess, {
      permission: "billing.manage",
    });
    return await getBillingAdapter(provider).createCheckout(ctx, { ... });
  },
});

HTTP Actions

HTTP actions handle incoming webhooks. They are registered in http.ts:

http.route({
  path: "/webhooks/polar",
  method: "POST",
  handler: handlePolarWebhook,
});

Internal vs Public

  • Public functions are exported with query, mutation, action and can be called from the client SDK.
  • Internal functions use internalQuery, internalMutation, internalAction and are only callable from other server-side functions.

Use internal functions for:

  • Webhook handlers that should not be client-callable
  • Helper functions called by actions
  • Grant/subscription management triggered by billing events

Common Patterns

Permission Guards

Nearly every public function starts with a permission check:

const actor = await requireAppPermission(ctx, {
  permission: "member.read",
});
// actor.organizationId, actor.appUserId, actor.role are now available

Organization Scoping

All tenant data is scoped by organizationId. Always filter queries using the actor's organization:

const records = await ctx.db
  .query("workspace_records")
  .withIndex("by_organizationId", (q) =>
    q.eq("organizationId", actor.organizationId),
  )
  .collect();

Structured Errors

Throw ConvexAppError (via factory functions) instead of plain Error so the error code and message reach the client:

import { notFound, forbidden, invalidInput } from "./errors";

throw notFound("Record not found.");
throw forbidden("Only owners can delete organizations.");
throw invalidInput("Enter a valid workspace name.");

Sensitive Action Guards

Destructive mutations include step-up verification:

await requireSensitiveActionInMutation(ctx, {
  userId: actor.appUserId,
  action: "organization.delete",
  organizationId: args.organizationId,
});

Zod Validation for User Input

User-facing input (workspace names, emails) is validated with Zod before reaching the database:

const result = workspaceName.safeParse(name);
if (!result.success) {
  throw invalidInput(result.error.issues[0]?.message);
}

Calling Functions from the Client

The product app uses the Convex React SDK:

import { useQuery, useMutation, useAction } from "convex/react";
import { api } from "@leavelocalhost/backend/convex/_generated/api";

// Reactive query subscription
const records = useQuery(api.workspaceRecords.listRecords);

// Mutation
const createRecord = useMutation(api.workspaceRecords.createRecord);
await createRecord({ title: "New Record", status: "open" });

// Action (non-reactive, returns a promise)
const checkout = useAction(api.billing.createCheckout);
const { url } = await checkout({ planKey: "pro_monthly" });

Next Reads

On this page