Skip to content

Features

Everything that comes out of one collection definition.

The Convex tables, the TypeScript types, the Zod validators, the admin forms, the typed read and write functions, and the access rules. Written once, in TypeScript.

What you get, concretely.

Everything below is shipped in the published packages and running in this site's admin panel. Anything still in progress lives on the roadmap.

Twelve field types

text, url, number, checkbox, select, date, color, upload, relationship, group, array, and blocks. Richtext lands with 0.1.0. json, email, and textarea follow after.

Schema and type generation

vex generate and vex dev write convex/vex.schema.ts, your TypeScript interfaces, and matching Zod validators straight from defineCollection.

Real-time data tables

Pagination, live totals, and bulk operations over a Convex subscription. Two editors working the same collection watch each other's changes arrive.

Media library

An upload field with a searchable, paginated picker over Convex file storage, available anywhere a document takes an image.

Roles, documents, and fields

Rules per collection, per operation, per document, and per field, plus per-call access.action and access.bypass overrides and an anonymous role for public reads.

Globals for the one-off content

defineGlobal gives you singletons — site settings, a header, a footer — with the same fields, the same forms, and the same access rules as a collection.

Database-driven themes

32 shadcn tokens per mode, light and dark, stored as OKLCH and applied on first paint. Change one and every open tab follows without a reload.

SEO, prerendering, and purge on save

vexMetadata, vexStaticParams, createVexSitemap, and createVexRobots for the public side, plus a revalidate route that drops the cached page when an editor hits save.

A test kit for your own components

@vexcms/react/testing exports the suites we run against every field input. Your custom fields can be held to the same contract.

Admin panel

A panel you can hand to a client.

The admin panel is a route in your own Next.js app, behind your own auth, on your own domain. It reads the same collection definitions your code does. Add a field and the form updates with no UI work.

  • Every list view is a Convex subscription, so rows and totals update while you watch.
  • Roles decide what each person can see and change, down to the individual field.
  • Themes live in the database. Branding the panel is content work, not a deploy.
  • A media picker with search and pagination over Convex file storage.
tsx
import { NextAdminPage } from "@vexcms/next/server"
import { redirect } from "next/navigation"

import { getToken } from "~/auth/server"
import config from "~/vex.config"

// The whole panel is one route in your app. Gate it however you
// gate anything else, then hand it the config you already wrote.
export default async function AdminPage({
  params,
}: {
  params: Promise<{ path?: string[] }>
}) {
  const token = await getToken()
  if (!token) redirect("/auth/sign-in?redirectTo=/admin")

  return <NextAdminPage config={config} params={params} token={token} />
}

One collection. Every layer, typed.

A testimonials collection any marketing site would have, and the Convex table vex dev writes from it. Validators, media ids, and the index you asked for, none of it typed out by hand.

You write
ts
export const testimonials = defineCollection({
  slug: "testimonials",
  admin: { useAsTitle: "author", icon: "Quote" },
  fields: {
    quote: text({ label: "Quote", required: true }),
    author: text({ label: "Author", required: true }),
    role: text({ label: "Role" }),
    company: text({ label: "Company", index: "by_company" }),
    companyUrl: url({ label: "Company URL" }),
    avatar: upload({ to: "images", label: "Avatar" }),
    rating: number({ label: "Rating" }),
    featured: checkbox({ label: "Featured" }),
    plan: select({
      label: "Plan",
      options: [
        { label: "Free", value: "free" },
        { label: "Pro", value: "pro" },
        { label: "Enterprise", value: "enterprise" },
      ],
      defaultValue: ["pro"],
    }),
  },
  labels: { singular: "Testimonial", plural: "Testimonials" },
})
Vex generates
ts
// ⚠️ AUTO-GENERATED BY VEX CMS — DO NOT EDIT ⚠️
// Run 'vex dev' or 'vex generate' to update this file.

import { defineTable } from "convex/server"
import { v } from "convex/values"

export const testimonials = defineTable({
  quote: v.string(),
  author: v.string(),
  role: v.optional(v.string()),
  company: v.optional(v.string()),
  companyUrl: v.optional(v.string()),
  avatar: v.optional(v.array(v.id("images"))),
  rating: v.optional(v.number()),
  featured: v.optional(v.boolean()),
  plan: v.optional(v.array(v.union(v.literal("free"), v.literal("pro"), v.literal("enterprise")))),
}).index("by_company", ["company"])

Access control

A scoped read stays scoped, inside the query.

Access rules carry constraints, and constraints compile to a withIndex range on the query itself. The narrowing happens in the database, not in a filter over documents you already paid to read.

  • Rules per collection, per operation, per document, and per field.
  • Indexed constraints rather than filtering a page of results after the fact.
  • An anonymous role, so public pages read without a session.
  • Per-call access.action and access.bypass for server code you already trust.
ts
export const access = defineAccess({
  roles: ["admin", "editor", "guest"] as const,
  anonRole: "guest",
  resources: [posts],
  permissions: {
    admin: { "*": true },
    editor: {
      posts: {
        // Editors browse their own posts, resolved straight off
        // the by_author index — no post-read filtering.
        read: {
          constraints: ({ user, q }) =>
            q.withIndex("by_author", (ix) => ix.eq("authorId", user._id)),
        },
        update: true,
      },
    },
    guest: {
      posts: {
        // Compiles to withIndex("by_status", ix => ix.eq("status", "published"))
        // on the query itself, so drafts are never read at all.
        read: {
          constraints: ({ q }) =>
            q.withIndex("by_status", (ix) => ix.eq("status", "published")),
        },
      },
    },
  },
})

Scaffold it and see.

One command gives you a Next.js app, a Convex deployment, auth, the admin panel, and a marketing site like this one. Seeded, editable, and yours.