Skip to main content

Best Practices

Here's a collection of our recommendations on how best to use Convex to build your application. If you want guidance specific to your app's needs or have discovered other ways of using Convex, message us on Discord!

Use TypeScript

All Convex libraries have complete type annotations and using theses types is a great way to learn the framework.

Even better, Convex supports code generation to create types that are specific to your app's schema and Convex functions.

Code generation is run automatically by npx convex dev.


Use argument validation in all public functions.

Argument validation prevents malicious users from calling your functions with the wrong types of arguments. It's okay to skip argument validation for internal functions because they are not publicly accessible.

Use console.log to debug your Convex functions.

All server-side logs from Convex functions are shown on the dashboard Logs page. If a server-side exception occurs, it will also be logged as an error event.

On a dev deployment the logs will also be forwarded to the client and will show up in the browser developer tools Console for the user who invoked the function call, including full server error messages and server-side stack traces.

Use helper functions to write shared code.

Write helper functions in your convex/ directory and use them within your Convex functions. Helpers can be a powerful way to share business logic, authorization code, and more.

Helper functions allow sharing code while still executing the entire query or mutation in a single transaction. For actions, sharing code via helper functions instead of using ctx.runAction reduces function calls and resource usage.

See the TypeScript page for useful types.

import { QueryCtx, mutation } from "./_generated/server";
import { v } from "convex/values";
import { getCurrentUser } from "./userHelpers";
import { Doc, Id } from "./_generated/dataModel";

export const remove = mutation({
args: { teamId:"teams") },
handler: async (ctx, { teamId }) => {
const currentUser = await getCurrentUser(ctx);
await ensureTeamAdmin(ctx, currentUser, teamId);
await ctx.db.delete(teamId);

async function ensureTeamAdmin(
ctx: QueryCtx,
user: Doc<"users">,
teamId: Id<"teams">,
) {
// use `ctx.db` to check that `user` is a team admin and throw an error otherwise
import { Doc } from "./_generated/dataModel";
import { QueryCtx } from "./_generated/server";

export async function getCurrentUser(ctx: QueryCtx): Promise<Doc<"users">> {
// load user details using `ctx.auth` and `ctx.db`

Prefer queries and mutations over actions

You should generally avoid using actions when the same goal can be achieved using queries or mutations. Since actions can have side effects, they can't be automatically retried nor their results cached. Actions should be used in more limited scenarios, such as calling third-party services.


Use indexes or paginate all large database queries.

Database indexes with range expressions allow you to write efficient database queries that only scan a small number of documents in the table. Pagination allows you to quickly display incremental lists of results. If your table could contain more than a few thousand documents, you should consider pagination or an index with a range expression to ensure that your queries stay fast.

For more details, check out our Introduction to Indexes and Query Performance article.

Use tables to separate logical object types.

Even though Convex does support nested documents, it is often better to put separate objects into separate tables and use Ids to create references between them. This will give you more flexibility when loading and querying documents.

You can read more about this at Document IDs.

UI patterns

Check for undefined to determine if a query is loading.

The useQuery React hook will return undefined when it is first mounted, before the query has been loaded from Convex. Once a query is loaded it will never be undefined again (even as the data reactively updates). undefined is not a valid return type for queries (you can see the types that Convex supports at Data Types)

You can use this as a signal for when to render loading indicators and placeholder UI.

Add optimistic updates for the interactions you want to feel snappy.

By default all relevant useQuery hooks will update automatically after a mutation is synced from Convex. If you would like some interactions to happen even faster, you can add optimistic updates to your useMutation calls so that the UI updates instantaneously.

Use an exception handling service and error boundaries to manage errors.

Inevitably, your Convex functions will have bugs and hit exceptions. If you have an exception handling service and error boundaries configured, you can ensure that you hear about these errors and your users see appropriate UI.

See Error Handling for more information.