# Rich text field (/docs/schema/fields/rich-text)

---
title: Rich text field
description: "The TipTap editor field. It stores a structured document in jsonb, or a markdown string in text, and renders a configurable WYSIWYG editor in the admin."
kind: reference
package: "@questpie/admin"
---

| Call                               | Column  | Value            | Validation                                  |
| ---------------------------------- | ------- | ---------------- | ------------------------------------------- |
| `f.richText()`                     | `jsonb` | `TipTapDocument` | recursive node schema rooted at `type: doc` |
| `f.richText({ mode: "json" })`     | `jsonb` | `TipTapDocument` | the same                                    |
| `f.richText({ mode: "markdown" })` | `text`  | `string`         | `z.string()`                                |

`mode` is the only option the factory takes, and it defaults to `"json"`. The
mode is recorded on the field metadata as `outputMode`, which is how the editor
knows whether to emit a document or a string.

```ts title="src/questpie/server/collections/posts.ts"
import { collection } from "#questpie/factories";

export const posts = collection("posts").fields(({ f }) => ({
	title: f.text(255).required(),
	body: f.richText().label("Body").required(),
	notes: f.richText({ mode: "markdown" }).label("Internal notes"),
}));
```

<Callout type="warn" title="`f.richText` needs the admin module">
	`@questpie/admin` contributes this type, so it appears on `f` only when that
	module is enabled and you import `collection` from `#questpie/factories`.
	Reading `f.richText` without it throws `Unknown field type: "richText"`.
</Callout>

## Chain

`richText` declares no methods of its own. The first four rows are base field
methods that work on any type. `.admin()` is not one of them. The admin plugin
registers it as a field extension and codegen wraps every factory with it.

| Method                                | Effect here                                                                                  |
| ------------------------------------- | -------------------------------------------------------------------------------------------- |
| `.required()`                         | `NOT NULL`, and required on insert                                                           |
| `.default(value)`                     | A `TipTapDocument` in json mode, a `string` in markdown. A factory or a raw `SQL` also works |
| `.localized()`                        | One document per locale                                                                      |
| `.label(text)` / `.description(text)` | Label and helper text on the admin form, each an `I18nText`                                  |
| `.admin(config)`                      | Editor options, listed below                                                                 |

## Editor options

`.admin()` config travels to the browser with the collection schema, so the
editor reads plain data. These are the keys it acts on.

| Option               | Type                                                | Default            | Effect                                                                    |
| -------------------- | --------------------------------------------------- | ------------------ | ------------------------------------------------------------------------- |
| `preset`             | `"minimal" \| "simple" \| "standard" \| "advanced"` | none               | Picks a feature set                                                       |
| `features`           | booleans keyed by feature                           | none               | Overrides single features on top of the preset, or of the defaults        |
| `placeholder`        | `string`                                            | `Start writing...` | Shown while the document is empty                                         |
| `showCharacterCount` | `boolean`                                           | `false`            | Prints the word and character readout, needs the `characterCount` feature |
| `maxCharacters`      | `number`                                            | none               | Hard limit in the editor, needs the `characterCount` feature              |
| `enableImages`       | `boolean`                                           | `true`             | Set `false` to hide image insertion, the extension stays on               |
| `imageCollection`    | `string`                                            | none               | Which upload collection inline images go to                               |
| `enableMediaLibrary` | `boolean`                                           | `true`             | Set `false` to drop the media picker from the image popover               |

```ts
content: f.richText().label("Content").admin({
	preset: "simple",
	features: { image: true },
	showCharacterCount: true,
	maxCharacters: 5000,
	imageCollection: "media",
}),
```

Inline image uploads go through the collection you name. Without
`imageCollection` the editor falls back to the admin's default upload
collection, or to the only one your app has. Several collections and no default
raises an error naming them.

<Callout type="warn" title="Field `.admin()` takes `unknown`">
	The generated `.admin()` on a field is typed `unknown`, so a misspelled key
	compiles and then does nothing. Collection-level `.admin()` is typed. Read the
	table above as the contract for this field.
</Callout>

### Presets

| Preset     | What it enables                                                           |
| ---------- | ------------------------------------------------------------------------- |
| no preset  | Every feature except `toolbar`                                            |
| `minimal`  | Only `bubbleMenu`, `history`, `bold`, `italic`, `underline` and `link`    |
| `simple`   | Everything except `codeBlock`, `align`, `image`, `table`, `tableControls` |
| `standard` | Every feature                                                             |
| `advanced` | The same set as `standard`                                                |

The feature keys are `toolbar`, `bubbleMenu`, `slashCommands`, `history`,
`heading`, `bold`, `italic`, `underline`, `strike`, `code`, `codeBlock`,
`blockquote`, `bulletList`, `orderedList`, `horizontalRule`, `align`, `link`,
`image`, `table`, `tableControls`, `characterCount`.

<Callout type="warn" title="`toolbar` is inert">
	Twenty of those twenty-one keys reach the editor. Nothing reads `toolbar`, so
	no preset renders one. Formatting runs through the bubble menu and slash
	commands. `toolbar` is also the only key separating no preset from `standard`,
	which leaves those two identical.
</Callout>

## Filtering

A json-mode field defines five operators of its own.

| Operator     | Operand   | Matches                                                |
| ------------ | --------- | ------------------------------------------------------ |
| `contains`   | `string`  | Any `text` node in the document, case-insensitive      |
| `isEmpty`    | `boolean` | Null, a doc with no content, or no non-whitespace text |
| `isNotEmpty` | `boolean` | A document with non-whitespace text                    |
| `isNull`     | `boolean` | The column is `NULL`                                   |
| `isNotNull`  | `boolean` | The column is not `NULL`                               |

```ts
const { docs } = await app.collections.posts.find({
	where: { body: { contains: "changelog", isNotEmpty: true } },
});
```

`contains` reads the words an editor typed, not node types or attribute values.
For which section types a page holds, reach for
[`f.blocks()`](/docs/schema/blocks) and its `hasBlockType` operator.

In json mode the four flag operators ignore their operand. `{ isEmpty: false }`
asserts the same condition as `{ isEmpty: true }`, so negate by using the
opposite operator or a `NOT` group.

A markdown-mode field stores plain text, so it filters with the standard string
operators instead: `eq`, `ne`, `contains`, `startsWith`, `endsWith`, `like`,
`ilike`, `in`, `isNull`, `isNotNull` and the rest of that set, matching against
the raw markdown. `isEmpty` and `isNotEmpty` belong to json mode.

<Callout type="warn" title="The `where` slot is not narrowed">
	A text column rejects a bad operator name at compile time. A rich text column
	does not, because its generated `where` type is a broad record. An operator
	this field lacks is dropped from the query rather than rejected, so check
	names against the table.
</Callout>

## Types

The document types ship from `@questpie/admin/fields`.

```ts
interface TipTapDocument {
	type: "doc";
	content?: TipTapNode[];
}

interface TipTapNode {
	type: string;
	attrs?: Record<string, any>;
	content?: TipTapNode[];
	marks?: Array<{ type: string; attrs?: Record<string, any> }>;
	text?: string;
}
```

```ts
import type { TipTapDocument } from "@questpie/admin/fields";

type Post = typeof posts.$infer.select;
//   ^? { ...; body: TipTapDocument; notes: string | null }
```

That subpath also exports the `richText` factory itself, `richTextFieldType`,
`RichTextFieldState`, the augmentable `RichTextFieldMeta` and `RichTextFeature`.
Ignore that last one, it is a stale union nothing reads and it is not the
feature list above. `RichTextMode` and `RichTextOptions` never leave the package.

## Related

- [Fields](/docs/schema/fields), every type and the column it produces
- [Blocks](/docs/schema/blocks), stacked and reorderable sections rather than
  one prose document
- [Textarea field](/docs/schema/fields/textarea), a plain multi-line string
- [Validation](/docs/schema/validation), tuning the generated Zod schemas
