Fields
Rich Text Field
The rich text field renders a TipTap WYSIWYG editor in the admin UI. By default it stores content as TipTap JSON, but can be configured to store HTML instead. This field is designed for long-form content like article bodies, page content, and descriptions that require formatting.
The underlying Convex validator is v.any() for JSON output or v.string() for HTML output, wrapped in v.optional() unless marked as required.
Config options
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
required | boolean | false | Makes the field required in the schema and admin UI | |
readOnly | boolean | false | Renders the editor as non-editable | |
placeholder | string | Placeholder text shown in the empty editor | ||
label | string | function | Field name | Custom label for the admin UI | |
description | string | Help text displayed below the field label | ||
output | "json" | "html" | "json" | Storage format for the editor content | |
minLines | number | Minimum number of visible lines. Sets the minimum editor height as a visual hint. | ||
maxLines | number | Maximum number of lines. When 1, the editor becomes single-line with only inline marks allowed. When greater than 1, paragraph breaks are capped at the specified count. | ||
maxCharacters | number | Maximum number of characters. Displays a live counter below the editor and blocks keyboard input and trims pasted content when the limit is reached. | ||
toolbar | RichTextToolbar | Toolbar button overrides. See Toolbar configuration below. | ||
allowRelativeLinks | boolean | false | Allow relative URLs (e.g. /trees) in the link dialog. Overrides the project-level richText.allowRelativeLinks setting. | |
condition | FieldCondition | Condition for showing or hiding this field | ||
sidebar | boolean | false | Place this field in the document sidebar | |
searchable | boolean | false | Include in full-text search indexing |
Example usage
import { f, defineVextroCollection } from "vextro";
export const pages = defineVextroCollection({
slug: "pages",
label: "Pages",
collectionType: "content",
tableName: "pages",
fields: {
title: f.text({ required: true }),
body: f.richText({
required: true,
placeholder: "Start writing...",
description: "Main page content with rich formatting",
}),
summary: f.richText({
output: "html",
description: "Brief summary stored as HTML",
}),
},
}); Single-line rich text
Use maxLines: 1 to create a constrained single-line editor that supports inline marks (bold, italic, strikethrough, inline code) but disables all block-level formatting (headings, lists, blockquotes, code blocks, horizontal rules). The Enter key is blocked and the toolbar only shows inline mark buttons.
This is useful for display fields like product names or store names that need limited rich text support — bold, superscript for trademarks (e.g., "TreeWorld®"), italic — but must remain on a single line.
export const products = defineVextroCollection({
slug: "products",
label: "Products",
collectionType: "content",
tableName: "products",
fields: {
name: f.richText({
required: true,
maxLines: 1,
label: "Product Name",
description: "Supports bold and superscript for trademarks",
}),
teaser: f.richText({
minLines: 1,
maxLines: 3,
description: "Short description, 1-3 lines",
}),
body: f.richText({
description: "Full product description",
}),
},
}); Line height behavior
The minLines option sets the minimum editor height as a visual hint. It does not enforce a minimum content length — the editor can be submitted empty unless required: true is also set.
When maxLines is greater than 1, the editor allows paragraph breaks up to the specified limit. Once the limit is reached, the Enter key is blocked to prevent additional paragraphs. Soft wrapping within a paragraph is not affected.
Character limit
When maxCharacters is set, a live character counter appears below the editor showing the current count against the limit (e.g. 42 / 280). Keyboard input is blocked when the limit is reached, and pasted content is automatically trimmed to fit.
summary: f.richText({ maxCharacters: 280 })
tweet: f.richText({ maxLines: 1, maxCharacters: 280 }) The counter text turns red when the character count exceeds the limit (which can happen if existing content was saved before the limit was added).
The character count is based on the plain text content of the editor, excluding HTML tags and formatting marks.
Admin options
The output option determines how content is stored. Use "json" (default) when you need to programmatically traverse or transform the content tree. Use "html" when you need the content as a rendered HTML string for direct insertion into templates.
Toolbar configuration
The toolbar option controls which buttons appear in the editor toolbar. All buttons are enabled by default. Set individual keys to false to hide them, configure heading levels, or restrict features to specific user roles.
body: f.richText({
toolbar: {
bold: true,
italic: true,
strikethrough: false,
underline: false,
code: true,
headings: [1, 2, 3], // only show H1, H2, H3
bulletList: true,
orderedList: true,
blockquote: true,
codeBlock: false,
horizontalRule: false,
link: true,
undoRedo: true,
sourceView: true, // enable the source view toggle
},
}) | Key | Type | Default | Description |
|---|---|---|---|
bold | boolean | { roles: string[] } | true | Bold mark (Cmd+B) |
italic | boolean | { roles: string[] } | true | Italic mark (Cmd+I) |
strikethrough | boolean | { roles: string[] } | true | Strikethrough mark (Cmd+Shift+X) |
underline | boolean | { roles: string[] } | true | Underline mark (Cmd+U) |
code | boolean | { roles: string[] } | true | Inline code mark (Cmd+E) |
headings | false | Array<1|2|3|4> | { roles: string[]; levels?: Array<1|2|3|4> } | [1,2,3,4] | Heading levels to enable. Set to false to disable all headings. Use { roles, levels } for role-based restriction. |
bulletList | boolean | { roles: string[] } | true | Bullet (unordered) list |
orderedList | boolean | { roles: string[] } | true | Ordered (numbered) list |
blockquote | boolean | { roles: string[] } | true | Blockquote |
codeBlock | boolean | { roles: string[] } | true | Fenced code block |
horizontalRule | boolean | { roles: string[] } | true | Horizontal rule / divider |
link | boolean | { roles: string[] } | true | Link insertion (Cmd+K) |
undoRedo | boolean | { roles: string[] } | true | Undo / redo buttons |
sourceView | boolean | { roles: string[] } | false | Source view toggle to inspect and edit raw JSON |
You can also set toolbar defaults at the project level using VextroConfig.richText.toolbar (Astro-side, booleans only) or createVextroAdminModule({ richText: { toolbar } }) (Convex-side, supports role-based restrictions). Field-level toolbar settings override project-level defaults. See Project-level rich text settings and Role-based toolbar restrictions below.
Role-based toolbar restrictions
Toolbar features can be restricted to users with specific roles by passing { roles: string[] } instead of a boolean. Role resolution happens server-side in the Convex query layer, so the editor component always receives plain booleans.
Role-based restrictions can be set at two levels:
- Module-level defaults via
createVextroAdminModule-- applied to all rich text fields - Field-level overrides via
f.richText({ toolbar })-- override module defaults per-key
// convex/admin.ts — Module-level defaults
const admin = createVextroAdminModule({
// ...
richText: {
toolbar: {
sourceView: { roles: ["admin", "developer"] },
codeBlock: { roles: ["developer"] },
},
},
}); // Field-level override — everyone gets code blocks on this field
body: f.richText({
toolbar: {
codeBlock: true,
headings: { roles: ["editor", "admin"], levels: [2, 3] },
},
})
Resolution order (last wins per-key):
- Module defaults (
createVextroAdminModule({ richText: { toolbar } })) -- role-aware, Convex-side - Field overrides (
f.richText({ toolbar })) -- role-aware, override module defaults per-key VextroConfigdefaults (VextroConfig.richText.toolbar) -- Astro-side, booleans only, fills remaining gaps
Steps 1 and 2 are merged and resolved against the user's roles in the Convex query. Step 3 is applied client-side in the Astro admin and only fills keys not already set.
The headings key supports an extended role-based form: { roles: string[]; levels?: Array<1|2|3|4> }. When the user has a matching role, the heading levels from levels are enabled (or all levels if levels is omitted). When the user does not match, headings are disabled entirely.
Source view
When sourceView is enabled in the toolbar, a </> button appears that toggles between the WYSIWYG editor and a raw JSON text area. This is useful for inspecting the stored document structure, debugging formatting issues, or manually editing mark attributes.
Changes made in source view are applied when toggling back to the WYSIWYG view. If the JSON is invalid, an error message is shown and the toggle is blocked until the JSON is corrected.
Relative links
By default, the link dialog validates URLs using the browser's built-in type="url" validation, which requires an absolute URL with a protocol (e.g. https://example.com). When allowRelativeLinks is enabled, the dialog accepts relative paths like /trees or /about/team.
Set this at the field level:
body: f.richText({
allowRelativeLinks: true,
}) Or at the project level to apply to all rich text fields:
export const adminConfig: VextroConfig = {
// ...
richText: {
allowRelativeLinks: true,
},
}; Field-level settings take precedence over the project-level default.
Project-level rich text settings
There are two places to set project-level rich text defaults:
Astro-side: VextroConfig
VextroConfig accepts a richText key that sets defaults for all rich text fields. These are boolean-only values applied client-side as a final fallback for unspecified keys.
import type { VextroConfig } from "vextro";
export const adminConfig: VextroConfig = {
// ...
richText: {
allowRelativeLinks: true,
toolbar: {
sourceView: true,
},
},
}; | Key | Type | Default | Description |
|---|---|---|---|
allowRelativeLinks | boolean | false | Allow relative URLs in link dialogs across all rich text fields |
toolbar | RichTextToolbar | Default toolbar overrides applied to all rich text fields |
Convex-side: createVextroAdminModule
For role-based toolbar restrictions, configure defaults in createVextroAdminModule. These are resolved server-side against the user's roles before the field metadata reaches the client.
const admin = createVextroAdminModule({
// ...
richText: {
toolbar: {
sourceView: { roles: ["admin", "developer"] },
codeBlock: { roles: ["developer"] },
},
},
}); | Key | Type | Default | Description |
|---|---|---|---|
richText.toolbar | RichTextToolbar | Module-level toolbar defaults with role-based restriction support |
Field-level toolbar and allowRelativeLinks options override both project-level defaults.
Content sanitization
When migrating content from other CMSes (e.g. PayloadCMS with Lexical editor), the rich text editor automatically sanitizes imported content on load:
- Double-encoded non-breaking spaces (
\u00C2\u00A0) are replaced with regular spaces - Non-breaking spaces (
\u00A0) are replaced with regular spaces - Lexical theme classes (e.g.
class: "LexicalEditorTheme__link") are stripped from link marks
This sanitization runs when content is loaded into the editor. The cleaned content is persisted the next time the document is saved, so imported documents are gradually cleaned as editors work with them.
Sanitization only affects how content is loaded into the editor. It does not modify stored data until an editor saves the document.
Conditional display
editorNotes: f.richText({
label: "Editor Notes",
condition: {
field: "userRole",
in: ["admin", "editor"],
},
description: "Internal notes visible only to editors",
})