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.
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.
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.
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
afterErrorhook -- if abeforeChangehook throws, the entire mutation rolls back. There is no partial state to clean up. afterChangehooks run after the database write within the same transaction. If anafterChangehook 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
- Convex Quickstart -- set up a Convex project from scratch
- Convex Tutorial -- a step-by-step guide to building with Convex
- Convex Discord -- ask questions and get help from the community
- Vextro Quick Start -- define your first collection and see the admin in action