Getting Started

Key Concepts

Vextro is built on Convex, a real-time backend-as-a-service. You do not need to be a Convex expert to use Vextro, but understanding a few core concepts will help you get the most out of the framework.

Already familiar with Convex?

If you have used Convex before, you can skip this page and go straight to the Quick Start.

What is Convex?

Convex is a backend platform that combines a database, server functions, and real-time sync into a single service. Instead of managing a separate database, API server, and WebSocket layer, you write TypeScript functions that Convex hosts and runs for you. Your frontend calls these functions directly -- no REST endpoints or GraphQL schemas to maintain.

Read the Convex overview

Functions: queries, mutations, and actions

Convex has three types of server functions:

  • Queries read data from the database. They are reactive -- when the underlying data changes, any client subscribed to that query automatically receives the update. Queries cannot modify data.
  • Mutations write data to the database. They run as atomic transactions: either all changes succeed or none do. Mutations can also read data.
  • Actions run arbitrary code including external API calls, file processing, and other side effects. Actions cannot directly read or write the database but can call mutations and queries internally.

Vextro hooks run inside mutations and queries. Collection beforeChange hooks run inside mutations. Collection afterRead hooks run inside queries. This is why hooks have access to ctx -- the Convex function context.

No REST or GraphQL needed

If you are coming from PayloadCMS, there is no /api/posts endpoint. Convex itself is the API. Your frontend imports the function references and calls them directly with full type safety:

import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";

// This is a direct, type-safe function call -- not an HTTP request
const posts = useQuery(api.public.listPublishedPosts);

The Convex client handles transport, caching, and real-time updates automatically. See Querying Data for framework-specific examples.

Real-time by default

Every Convex query is a live subscription. When data changes in the database, all clients watching that query receive the update instantly -- no polling, no manual refetch, no WebSocket setup. This is built into the platform.

In the Vextro admin panel, this means document lists and editors update live. If two editors are working on the same collection, both see new documents appear in real time.

Learn about Convex reactivity

Indexes, not filters

Convex requires you to define indexes for efficient lookups. The withIndex() method uses an index to find documents quickly. The .filter() method scans every document in the table -- avoid it for anything other than small result sets.

// Efficient: uses the by_status index
const published = await ctx.db
  .query("posts")
  .withIndex("by_status", (q) => q.eq("status", "published"))
  .collect();

// Slow: scans every document in the table
const published = await ctx.db
  .query("posts")
  .filter((q) => q.eq(q.field("status"), "published"))
  .collect();

Vextro auto-generates indexes for common patterns (by slug, by status) when you define collections with defineVextroCollection.

Learn about Convex indexes

Atomic transactions

Convex mutations are fully transactional. If any part of a mutation throws an error, all database changes made in that mutation are rolled back. Nothing is partially written.

This has implications for Vextro hooks:

  • No afterError hook -- if a beforeChange hook throws, the entire mutation rolls back. There is no partial state to clean up.
  • afterChange hooks run after the database write within the same transaction. If an afterChange hook throws, the entire mutation fails and the write is rolled back.

This transactional model is simpler than systems that need error recovery hooks because there is no inconsistent state to recover from.

Argument validation

Convex validates function arguments before the handler runs. Every query and mutation declares its expected argument types, and Convex rejects calls with invalid arguments before your code executes.

export const getPost = query({
  args: { slug: v.string() },     // validated before handler runs
  returns: v.union(v.any(), v.null()),
  handler: async (ctx, { slug }) => {
    // slug is guaranteed to be a string here
    return await ctx.db
      .query("posts")
      .withIndex("by_slug", (q) => q.eq("slug", slug))
      .unique();
  },
});

This is why Vextro has no beforeValidate hook -- validation happens at the Convex platform level before any application code runs. Vextro's f field builders generate these validators automatically.

Learn about Convex argument validation

Where to learn more

Previous
Introduction