Getting Started
Quick Start
This guide walks through defining a "Pages" collection with the f field builders, wiring it into your Convex schema, and viewing it in the admin panel. By the end, you will have a working collection with a list view and document editor.
Define a collection
Create a collection definition file. This is where you declare the fields, their types, and how they appear in the admin UI.
Create src/collections/pages.ts:
import { f, defineVextroCollection } from "vextro";
export const pages = defineVextroCollection({
slug: "pages",
label: "Pages",
description: "Top-level site pages",
group: "Content",
collectionType: "content",
tableName: "pages",
useAsTitle: "title",
fields: {
title: f.text({
required: true,
searchable: true,
listColumn: true,
placeholder: "Page title",
}),
slug: f.slug({
sourceField: "title",
required: true,
listColumn: true,
}),
content: f.richText({
placeholder: "Start writing...",
}),
status: f.select({
options: [
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
{ label: "Scheduled", value: "scheduled" },
{ label: "Trashed", value: "trashed" },
],
required: true,
sidebar: true,
sidebarSection: "document",
listColumn: true,
listColumnWidth: "small",
}),
},
listConfig: {
columns: ["title", "slug", "status"],
searchableFields: ["title", "slug"],
defaultSort: "updatedAt",
defaultSortDirection: "desc",
},
}); Let's break down what each part does:
slugandtableNametie this collection to thepagestable in Convex.collectionType: "content"maps to thecontenttrait preset, which enables status workflow (draft, published, scheduled, trashed), auto-timestamps, and audit fields.useAsTitle: "title"tells the admin to display thetitlefield as the document name in lists and breadcrumbs.f.text()creates a single-line text input. Settingrequired: truemakes the Convex validator required and shows validation in the admin form.f.slug()creates a slug input that auto-generates from thetitlefield. As the user types a title, the slug updates in real time.f.richText()adds a TipTap WYSIWYG editor for page content.f.select()renders a dropdown. Thesidebar: trueoption moves it to the document sidebar instead of the main editing area.listColumn: truemarks a field as a default column in the collection list view.
Wire into Convex schema
The collection definition generates a Convex table definition with the correct validators and indexes. Use it in your convex/schema.ts:
import { defineSchema } from "convex/server";
import { vextroTables } from "./vextroTables";
import { pages } from "../src/collections/pages";
export default defineSchema({
...vextroTables,
pages: pages.table,
}); The pages.table property is a fully configured Convex defineTable() call with:
- All field validators extracted from the
fbuilders - A
by_statusindex (auto-added for content collections) - A
by_slugindex (auto-added whenfieldNames.slugis configured) - Auto-injected
updatedAt(number) andupdatedBy(optional string) fields
Fields map directly to Convex validators
Each f builder produces a Convex validator internally. For example, f.text({ required: true }) becomes v.string(), while f.text() (optional by default) becomes v.optional(v.string()). The f.select({ options: ["draft", "published"] }) becomes v.union(v.literal("draft"), v.literal("published")). You never write validators by hand -- the builders handle it.
Register the collection
Update your collection registry so the admin module knows about your collection. Edit convex/lib/collectionRegistry.ts:
type CollectionDefinition = {
slug: string;
label: string;
description?: string;
group?: string;
collectionType?: string;
tableName: string;
};
export const collectionDefinitions: Array<CollectionDefinition> = [
{
slug: "pages",
label: "Pages",
description: "Top-level site pages",
group: "Content",
collectionType: "content",
tableName: "pages",
},
]; Deploy and run
Deploy the updated schema:
npx convex dev Run the dev server
Two terminals
You need both the Convex dev server and the Astro dev server running simultaneously. Keep npx convex dev running in one terminal and start Astro in another.
pnpm dev Open http://localhost:4321 in your browser. After signing in, you should see the admin sidebar with a "Content" group containing a "Pages" link.
What you will see
Collection list page
Clicking "Pages" in the sidebar opens the collection list. The list shows the columns you configured: Title, Slug, and Status. The list supports:
- Search -- type in the search bar to filter by title or slug (the fields marked with
searchable: true) - Sorting -- click column headers to sort. The default sort is
updatedAtdescending. - Status filtering -- filter by draft, published, scheduled, or trashed documents
- Actions -- buttons for New, Import, Export, and bulk Delete
Document editor
Click "New" to create a page. The editor renders each field as the appropriate input:
- Title -- a single-line text input at the top of the form
- Slug -- a slug input below the title that auto-generates from what you type in the title field
- Content -- a rich text editor with formatting toolbar (headings, bold, italic, links, lists, code blocks)
- Status -- a select dropdown in the sidebar panel on the right
The editor auto-saves as you type. The sidebar shows the document status, timestamps, and the save/publish actions.
Adding more fields
Here is an expanded version of the pages collection with additional field types:
import { f, defineVextroCollection } from "vextro";
export const pages = defineVextroCollection({
slug: "pages",
label: "Pages",
description: "Top-level site pages",
group: "Content",
collectionType: "content",
tableName: "pages",
useAsTitle: "title",
versions: { enabled: true, maxVersions: 25 },
fields: {
title: f.text({ required: true, searchable: true, listColumn: true }),
slug: f.slug({ sourceField: "title", required: true, listColumn: true }),
content: f.richText({ output: "json" }),
// SEO fields in a collapsible section
seo: f.group({
label: "SEO",
collapsible: true,
collapsed: true,
fields: {
metaTitle: f.text({ placeholder: "Override page title for search engines" }),
metaDescription: f.textarea({ rows: 3, placeholder: "155 characters max" }),
ogImage: f.image({ relationTo: "media" }),
},
}),
// Sidebar fields
status: f.select({
options: ["draft", "published", "scheduled", "trashed"],
required: true,
sidebar: true,
sidebarSection: "document",
listColumn: true,
listColumnWidth: "small",
}),
publishedAt: f.datetime({
sidebar: true,
sidebarSection: "document",
listColumn: true,
listColumnWidth: "medium",
}),
author: f.id("users", {
sidebar: true,
sidebarSection: "document",
}),
featured: f.checkbox({
sidebar: true,
label: "Feature on homepage",
}),
tags: f.select({
options: ["marketing", "product", "engineering", "company"],
multiple: true,
sidebar: true,
}),
},
listConfig: {
columns: ["title", "status", "publishedAt", "slug"],
searchableFields: ["title", "slug"],
defaultSort: "updatedAt",
defaultSortDirection: "desc",
},
}); This expanded definition demonstrates:
f.group()-- nests SEO fields into a collapsible object stored as{ metaTitle, metaDescription, ogImage }f.image()-- references an upload collection ("media") for image selectionf.datetime()-- renders a date/time picker, stored as a Unix timestampf.id("users")-- creates a relationship picker that lets editors select a user documentf.checkbox()-- renders a toggle switch in the sidebarf.select({ multiple: true })-- renders a multi-select input for tagsversions: { enabled: true }-- enables version history with a maximum of 25 snapshots per document
Field type reference
Here is a quick reference of the most commonly used field builders:
| Builder | Input type | Storage type |
|---|---|---|
f.text() | Single-line text | string |
f.textarea() | Multi-line text | string |
f.richText() | TipTap WYSIWYG editor | string or object |
f.number() | Numeric input | number |
f.slug() | Slug with auto-generation | string |
f.select() | Dropdown / multi-select | string or string[] |
f.checkbox() | Toggle switch | boolean |
f.datetime() | Date and time picker | number (timestamp) |
f.id("table") | Relationship picker | Id<"table"> |
f.image() | Image upload | string (file reference) |
f.code() | CodeMirror editor | string |
f.json() | JSON editor | any |
f.group() | Nested object | object |
f.array() | Repeating field groups | array |
f.blocks() | Flexible content blocks | array |
Every builder accepts required, readOnly, label, description, condition, sidebar, listColumn, and searchable as common options. See the Fields documentation for the full API reference.
Fetching data on your frontend
The admin panel manages your content, but your frontend application needs to read it too. Since Vextro collections are regular Convex tables, you write standard Convex query functions to fetch data.
Create a query in your convex/ directory:
// convex/public.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
export const listPublishedPages = query({
args: {},
returns: v.array(v.any()),
handler: async (ctx) => {
return await ctx.db
.query("pages")
.withIndex("by_status", (q) => q.eq("status", "published"))
.order("desc")
.take(20);
},
}); Then call it from your Astro page:
---
// src/pages/index.astro
import { ConvexHttpClient } from "convex/browser";
import { api } from "../../convex/_generated/api";
const convex = new ConvexHttpClient(import.meta.env.PUBLIC_CONVEX_URL);
const pages = await convex.query(api.public.listPublishedPages);
---
<ul>
{pages.map((page) => (
<li><a href={`/${page.slug}`}>{page.title}</a></li>
))}
</ul> See Querying Data for complete examples with Astro, Svelte, and Next.js -- including pagination, relationship resolution, and real-time subscriptions.
Next steps
- Explore the full field type documentation for detailed options on each builder
- Read Querying Data to learn how to fetch content from your frontend
- Set up uploads to enable image and file management
- Configure blocks for flexible page layouts
- Enable version history to track document changes