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,actionand can be called from the client SDK. - Internal functions use
internalQuery,internalMutation,internalActionand 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 availableOrganization 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
- Convex Backend — backend directory structure.
- Security Model — permission and verification patterns.
Data Model
The database schema is defined in packages/backend/convex/schema.ts. Convex uses a document-oriented model — each table stores JSON documents with validated shapes.
Security Model
Leave Localhost implements a layered security model: authentication via Better Auth, authorization via role + capability checks, and step-up verification for sensitive actions.