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:

  • slug and tableName tie this collection to the pages table in Convex.
  • collectionType: "content" maps to the content trait preset, which enables status workflow (draft, published, scheduled, trashed), auto-timestamps, and audit fields.
  • useAsTitle: "title" tells the admin to display the title field as the document name in lists and breadcrumbs.
  • f.text() creates a single-line text input. Setting required: true makes the Convex validator required and shows validation in the admin form.
  • f.slug() creates a slug input that auto-generates from the title field. 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. The sidebar: true option moves it to the document sidebar instead of the main editing area.
  • listColumn: true marks 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 f builders
  • A by_status index (auto-added for content collections)
  • A by_slug index (auto-added when fieldNames.slug is configured)
  • Auto-injected updatedAt (number) and updatedBy (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 updatedAt descending.
  • 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 selection
  • f.datetime() -- renders a date/time picker, stored as a Unix timestamp
  • f.id("users") -- creates a relationship picker that lets editors select a user document
  • f.checkbox() -- renders a toggle switch in the sidebar
  • f.select({ multiple: true }) -- renders a multi-select input for tags
  • versions: { 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:

BuilderInput typeStorage type
f.text()Single-line textstring
f.textarea()Multi-line textstring
f.richText()TipTap WYSIWYG editorstring or object
f.number()Numeric inputnumber
f.slug()Slug with auto-generationstring
f.select()Dropdown / multi-selectstring or string[]
f.checkbox()Toggle switchboolean
f.datetime()Date and time pickernumber (timestamp)
f.id("table")Relationship pickerId<"table">
f.image()Image uploadstring (file reference)
f.code()CodeMirror editorstring
f.json()JSON editorany
f.group()Nested objectobject
f.array()Repeating field groupsarray
f.blocks()Flexible content blocksarray

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

Previous
Installation