Skip to content

Globals

VexCMS supports globals for content that exists exactly once per project — site settings, primary navigation, a footer — as distinct from collections, which hold many documents. A global is a single flat document reachable by its slug, with its own admin edit page but no list view and no relationship-picker target.

  • defineGlobal() builds a GlobalConfig, registered via defineConfig({ globals: [...] }) alongside collections.
  • Every global lives in one shared vex_globals table, discriminated by _slug. The API layer flattens the DB row into a flat document on read (getGlobal/findGlobals) and re-nests it on write (updateGlobal) — so siteSettings.siteName reads as a root-level property alongside the system fields _id, _creationTime, _slug.
  • _id, _creationTime, and _slug are reserved field keys — defineGlobal rejects them both at compile time and at runtime.
  • Globals accept the same field types as collections (text, number, checkbox, select, date, url, relationship, upload, array, group, blocks), but can’t be a relationship target — there’s no picker UI for a singleton — and have no useAsTitle; the admin page title is always label.
  • The admin panel auto-renders an edit view for every registered global. No extra wiring is needed to make a global editable under /admin.
vex.config.ts
import { defineConfig, defineGlobal, text, checkbox, relationship } from "@vexcms/core";
import { themes } from "./collections/themes";
export const siteSettings = defineGlobal({
slug: "siteSettings",
label: "Site Settings",
fields: {
siteName: text({ label: "Site Name", required: true }),
maintenanceMode: checkbox({ label: "Maintenance Mode" }),
activeTheme: relationship({ label: "Active Theme", collection: { slug: "themes" } }),
},
admin: { group: "Site Builder", icon: "Settings" },
});
export default defineConfig({
collections: [themes],
globals: [siteSettings],
});

slug is the lookup key in vex_globals and must be unique across every registered global. admin.group puts the global under a labeled section in the sidebar; omit it to leave the global ungrouped.

Client components use the same tanstack-query wrappers as collections, imported from @vexcms/react/client:

"use client";
import { useQuery } from "@tanstack/react-query";
import { getGlobal } from "@vexcms/react/client";
export function SiteHeader() {
const { data } = useQuery(getGlobal({ slug: "siteSettings" }));
if (!data) return null;
return <span>{data.siteName}</span>;
}

Populate a relationship field the same way find/get do:

const { data } = useQuery(
getGlobal({ slug: "siteSettings", populate: { activeTheme: true } }),
);
data?.activeTheme; // resolved theme document | undefined

List every saved global at once — the result is un-narrowed, since each row’s _slug picks a different shape:

import { findGlobals } from "@vexcms/react/client";
const { data } = useQuery(findGlobals());
data?.map((g) => g._slug); // e.g. ["siteSettings", "nav"]

On the server — a Convex query/mutation handler, or an RSC loader via fetchQuery — import from @vexcms/core/server instead, which takes ctx and the resolved config rather than returning tanstack-query options:

convex/layoutData.ts
import { query } from "./_generated/server";
import { getGlobal } from "@vexcms/core/server";
import config from "../vex.config.server";
export const layoutData = query({
handler: async (ctx) => {
const settings = await getGlobal({ ctx, config, slug: "siteSettings" });
return { siteName: settings?.siteName };
},
});

Reach for useUpdateGlobal when building a custom editing UI:

"use client";
import { useMutation } from "@tanstack/react-query";
import { useUpdateGlobal } from "@vexcms/react/client";
function SiteNameForm() {
const updateGlobalMutationFn = useUpdateGlobal();
const mutation = useMutation({ mutationFn: updateGlobalMutationFn });
return (
<button
onClick={() => mutation.mutate({ slug: "siteSettings", data: { siteName: "New Name" } })}
>
Save
</button>
);
}

Pass only user field values in data — system keys (_id, _creationTime, _slug) are stripped server-side if they end up in there.

When defineAccess is configured, updateGlobal/globals.upsert authorizes writes against create or update — never read — because a global is a singleton and the verb depends on whether it has ever been saved: the first write for a slug authorizes as create, every later one as update. A role that can read a global can no longer write it just from that grant; create and/or update must be granted explicitly. access.action overrides this the same way it does everywhere else in the Local API.

The write also merges the payload into the stored document rather than replacing it: an omitted field is left untouched, not deleted. A save carrying only { siteName: "New Name" } leaves every other field on siteSettings exactly as it was — the merged result is what gets validated against the global’s Zod schema, so a partial payload is still rejected if the document it produces would be invalid.

Field-level permissions apply the same way they do to a collection: an update filter callback that returns a field map denies on the first changed key it forbids, and a field resent with its unchanged value is not a violation. See the Access Control guide for the map shapes and the apps/www siteSettings.adminTheme worked example.

array and group compose on a global exactly the way they do on a collection. This is the real nav global from the marketing site — an editable, reorderable list of nav links:

import { array, defineGlobal, group, text } from "@vexcms/core";
export const nav = defineGlobal({
slug: "nav",
label: "Nav",
admin: { icon: "TableProperties" },
fields: {
items: array({
items: group({
fields: {
title: text(),
href: text(),
},
}),
}),
},
});
  • Reserved keys. _id, _creationTime, _slug can’t be field keys — a compile error on the offending field, plus a thrown Error at runtime for JS callers.
  • No relationship target. A global can’t be the collection of another relationship() field — there’s no admin picker for a singleton.
  • versions.drafts is parsed but not enforced yet. defineGlobal({ versions: { drafts: true } }) type-checks and is stored on the resolved config, but draft/publish workflow for globals isn’t implemented — every read returns the live document regardless of this setting.
  • interfaceName defaults from slug + a Global suffix ("siteSettings" → "SiteSettingsGlobal") — pass interfaceName explicitly if you need a different generated TypeScript name.
  • Client and server globals helpers are separate entry points with the same names. @vexcms/react/client (getGlobal, findGlobals, updateGlobal — tanstack-query based) vs. @vexcms/core/server (identical names, ctx-based, called from inside Convex functions). Import from whichever matches where the code runs — the client versions forbid ctx, the server versions require it, so mixing them up is a type error.
  • A read grant no longer authorizes a write. updateGlobal/globals.upsert checks create/update, not read — see Access control above. Upgrading from a version that predates this needs the role’s permissions updated explicitly.