Rich Text
The RichText field renders a WYSIWYG editor with a configurable toolbar. It submits an HTML string, or null when the editor is empty. The PHP field adds a string rule automatically, so optional editors should also call nullable().
Basic Usage
use InertiaUI\Forms\Fields\RichText;
RichText::make('body')->nullable();This renders an editor with the default toolbar: bold, italic, underline, strike, an H2 heading, lists, links, blockquotes, inline code, undo, and redo.
Toolbar Presets
Simple
A minimal toolbar with just bold, italic, and link:
RichText::make('bio')->simple();Full
The full preset adds the larger editor controls, including image embeds, task lists, and text alignment:
RichText::make('content')->full();Custom
Pass your own toolbar configuration:
RichText::make('notes')
->toolbar('bold italic | bullet ordered ~ link undo redo');You may also pass an array. Each nested array becomes a visual button group:
RichText::make('notes')->toolbar([
['bold', 'italic'],
['bulletList', 'orderedList'],
['link'],
]);Flat arrays are accepted too:
RichText::make('notes')->toolbar([
'bold', 'italic', '|', 'bulletList', 'orderedList', '|', 'link',
]);Use | as a visual separator between button groups and ~ as a flexible spacer. The string grammar also accepts these aliases: bullet for bulletList, ordered for orderedList, quote for blockquote, hr for horizontalRule, and h1, h2, or h3 for heading.
RichText::normalizeToolbar() exposes the same normalization when application code needs the canonical flat toolbar array without building a field instance.
h1, h2, and h3 all normalize to the single heading toolbar control. That control toggles an H2 heading; it is not a heading-level picker.
Available Toolbar Items
| Item | Description |
|---|---|
bold | Bold text |
italic | Italic text |
underline | Underlined text |
strike | Strikethrough text |
heading | Toggle an H2 heading |
highlight | Highlighted text |
subscript | Subscript text |
superscript | Superscript text |
bulletList | Unordered list |
orderedList | Numbered list |
taskList | Checkbox list |
link | Hyperlink |
image | Image embed |
blockquote | Block quote |
code | Inline code |
codeBlock | Code block |
horizontalRule | Horizontal divider |
alignLeft | Left alignment |
alignCenter | Center alignment |
alignRight | Right alignment |
undo | Undo |
redo | Redo |
Link Options
The link toolbar item opens a native modal dialog for inserting, editing, and removing links. Configure the generated link policy from PHP:
RichText::make('body')
->simple()
->linkTargetBlank()
->linkNoopener()
->linkNoreferrer()
->linkAllowedProtocols(['https', 'mailto'])
->linkDefaultProtocol('https');By default, links open in the same tab, noopener is enabled for links that open in a new tab, noreferrer is disabled, auto-linking and link-on-paste are enabled, the default protocol is http, and allowed protocols are http, https, mailto, and tel.
Use autoLink(false) to stop converting typed URLs and linkOnPaste(false) to stop converting a pasted URL. These switches do not disable the link dialog. linkAllowedProtocols() normalizes and deduplicates protocol names. Removing the current default protocol makes the first allowed protocol the new default. linkDefaultProtocol() must select a protocol that is already allowed.
Use allowLinkTargetOverride(false) when the field configuration should fully control whether links open in a new tab. With target override allowed, authors may toggle "Open in new tab" per link.
RichText::make('body')
->linkTargetBlank()
->allowLinkTargetOverride(false);Use linkRel() for additional rel tokens such as nofollow, sponsored, or ugc:
RichText::make('body')
->linkRel(['nofollow', 'ugc']);Protocol options are validated when the form is built. Unsafe or malformed protocols such as javascript: and data: are rejected, and the editor also rejects links whose protocol is not allowed. This policy applies to links created or normalized by the editor; it is not a replacement for server-side HTML sanitization when accepting untrusted HTML.
Height
Set minimum and/or maximum heights for the editor area in pixels:
RichText::make('content')
->minHeight(200)
->maxHeight(600);Image Embeds
The image toolbar item accepts image URLs by default. Add imageUploads() when authors should also be able to pick an image file from their device:
use InertiaUI\Forms\Fields\RichText;
use InertiaUI\Forms\FileUploads\UploadConfig;
RichText::make('body')
->full()
->imageUploads(fn (UploadConfig $uploads) => $uploads
->maxSize(2048)
->maxDimensions(width: 2400, height: 1600));RichText image uploads use the same temporary upload pipeline as the File field. Configure custom routes, chunking, direct-to-storage uploads, upload validators, and token validation in File Uploads. Common image validation helpers such as accept(), maxSize(), minSize(), dimensions(), minDimensions(), and maxDimensions() run during the upload request. The upload config always includes Laravel's image rule; accept() narrows the allowed image MIME types and must not turn this into a general file upload.
The generated companion field is <name>_images, for example body_images. Use getImageUploadFieldName() on the field or RichTextUploads::imageUploadFieldName('body') when application code needs that name explicitly.
storeWithForm() is not supported for RichText images. RichText still submits an HTML string for the field itself. Uploaded images are tracked in a companion token list, so your controller may resolve the temporary uploads and rewrite the HTML before storing it.
Image Resizing
Images inserted by URL or uploaded through imageUploads() may be resized in the editor with corner handles. The editor stores the user's resized display size as numeric width and height attributes on the <img> tag, so the submitted value stays regular HTML. RichTextUploads and RichTextContent preserve those attributes when they rewrite temporary upload markers or stored-image references.
Storage and rendering callbacks receive the existing image attributes through the RichTextImage builder. Use RichTextImage::width(), height(), or dimensions() when your application wants to normalize or override the user's resized display size while storing or rendering an image. These fluent setters write the rendered <img width height> attributes. They are not getters and do not describe the source image's intrinsic pixel dimensions. Use upload validation helpers such as UploadConfig::dimensions() and maxDimensions() when you need to validate the actual uploaded file.
Store Uploaded Images
Use the validated Form in your controller or form request handler. The richText() accessor applies the matching field's imageUploads() policy, verifies the HTML markers against the companion tokens, and deletes temporary uploads after a successful rewrite:
use InertiaUI\Forms\FileUploads\SubmittedUpload;
use InertiaUI\Forms\RichText\RichTextImage;
$html = $form->richText('body')
->storeImagesUsing(function (SubmittedUpload $upload, RichTextImage $image): RichTextImage {
$storedImage = app(ContentImageStore::class)->store($upload->getUploadedFile());
return $image
->src($storedImage->url())
->identifier($storedImage->uuid(), [
'id' => $storedImage->id(),
]);
})
->toHtml();The callback may mutate the provided RichTextImage builder without returning it. It may also return that builder, a new RichTextImage instance, or null. Returning arrays is intentionally unsupported so the replacement shape stays typed and readable.
RichTextImage may set src(), alt(), title(), width(), height(), or both dimensions together. attribute() and attributes() set other syntactically valid image attributes; pass null or false to remove one. identifier() writes a durable application-owned identifier plus optional metadata for deferred rendering, and toAttributes() returns the final attribute map.
Image Upload Validation
The Form accessor validates each image against the matching field's MIME type, size, and dimension policy before storage. Missing, unauthorized, hidden, non-RichText, and image-upload-disabled fields throw a RuntimeException.
Direct processing outside a Form must provide its policy explicitly:
use InertiaUI\Forms\FileUploads\UploadConfig;
use InertiaUI\Forms\RichText\RichTextUploads;
$html = RichTextUploads::from($request, 'body')
->validateUsing(UploadConfig::images()
->maxSize(2048)
->maxDimensions(width: 2400, height: 1600))
->storeImagesInMediaLibrary($post, 'content-images')
->toHtml();You may also use forField($field) to apply a RichText field's policy in a non-Form workflow. RichTextUploads::from() does not discover a policy from the ambient request.
Invalid MIME types, sizes, and dimensions throw a ValidationException before storage.
withoutImageValidation() accepts any file behind a valid upload token. Use it only when your own pipeline validates the files.
Spatie Media Library
Use storeImagesInMediaLibrary() to store uploaded images on a model that implements Spatie Media Library's HasMedia contract:
$post->update([
'body' => $form->richText('body')
->storeImagesInMediaLibrary($post, 'content-images')
->toHtml(),
]);The helper stores each image in the given collection, replaces its temporary src with the Media Library URL, and records a signed image reference for later rendering. Pass the optional disk argument to override the collection's configured disk.
The model must already be saved. Create workflows should persist the model before moving its RichText images:
use Illuminate\Support\Arr;
$post = Post::create(
Arr::except($form->validated(files: false), 'body'),
);
$post->body = $form->richText('body')
->storeImagesInMediaLibrary($post, 'content-images')
->toHtml();
$post->save();Removing Unused Images
Images removed from the editor remain in the collection by default. Pass deleteUnused: true to keep an exclusively owned collection in sync with the stored HTML:
$post->body = $form->richText('body')
->storeImagesInMediaLibrary(
$post,
'content-images',
deleteUnused: true,
)
->toHtml();
$post->save();Cleanup runs only after every new image is stored and the HTML is rewritten. An empty body clears the collection. Only valid signed references for the same collection retain media.
Each image keeps its client original filename. A successful rewrite deletes its staged upload. A failed rewrite removes newly created Media records and keeps the staged upload available for retry. The scheduled upload cleanup command remains the safety net for abandoned files.
Deferred Image Rendering
The default storage helper writes final image URLs into the HTML. Use keepTokenized() instead when URLs should be resolved while rendering. Common examples include private media, queued responsive images, and tenant-specific URLs.
$post->body = $form->richText('body')
->keepTokenized()
->storeImagesInMediaLibrary($post, 'content-images')
->toHtml();Resolve each stored reference before sending the HTML to the browser:
use InertiaUI\Forms\RichText\RichTextContent;
use InertiaUI\Forms\RichText\RichTextImage;
use InertiaUI\Forms\RichText\RichTextStoredImage;
$html = RichTextContent::from($post->body)
->replaceImagesUsing(function (RichTextStoredImage $stored, RichTextImage $image): void {
$image->src(route('content-images.show', $stored->identifier()));
})
->toHtml();The RichTextStoredImage passed to the callback exposes identifier(), all metadata(), a nested meta($key, $default) lookup, and the original image attributes(). The RichTextImage builder is mutable, so the callback does not need to return it. Returning a different RichTextImage replaces the builder; returning null without changing it preserves the original attributes. RichTextContent::toHtml() returns null when its input was null.
Rendered HTML removes data-inertia-forms-image attributes by default, including unresolved and malformed references. Ordinary image attributes remain. Preserve valid references in HTML that will be loaded back into the editor:
$editorHtml = RichTextContent::from($post->body)
->preserveStoredImageReferences()
->toHtml();Replacement callbacks may preserve a reference, remove it with attribute(RichTextImage::STORED_ATTRIBUTE, null), or replace it with identifier().
Rendering Media Library Images
Public Media
A render callback may return a Media Library Media model directly. Inertia Forms maps it onto the image builder:
use Spatie\MediaLibrary\MediaCollections\Models\Media;
$media = $post->getMedia('content-images')->keyBy('uuid');
$html = RichTextContent::from($post->body)
->replaceImagesUsing(function (RichTextStoredImage $stored) use ($media): ?Media {
return $media->get($stored->identifier());
})
->toHtml();The lookup should already be scoped to the model and collection being rendered. Applications with tenant or viewer rules must authorize the resolved Media item before returning it.
Use fromMediaLibrary() explicitly to select a conversion or add responsive image attributes:
$media = $post->getMedia('content-images')->keyBy('uuid');
$html = RichTextContent::from($post->body)
->replaceImagesUsing(function (
RichTextStoredImage $stored,
RichTextImage $image,
) use ($media): void {
$storedMedia = $media->get($stored->identifier());
if (! $storedMedia) {
return;
}
$image
->fromMediaLibrary($storedMedia, 'web')
->attribute('sizes', '(min-width: 768px) 720px, 100vw')
->attribute('loading', 'lazy');
})
->toHtml();fromMediaLibrary() writes the full URL and available srcset, then records the Media Library identifier. Existing editor dimensions are preserved. Images without editor dimensions receive the largest responsive variant's intrinsic size. The Media name becomes alt text only when the original image has no alt attribute.
Private Media
fromMediaLibrary() is a public-media shorthand. It writes the Media Library model's durable URL into the image. Private media needs a fresh authorized URL each time the content is rendered:
$media = $post->getMedia('content-images')->keyBy('uuid');
$html = RichTextContent::from($post->body)
->requireSignedImages()
->replaceImagesUsing(function (
RichTextStoredImage $stored,
RichTextImage $image,
) use ($media): void {
$storedMedia = $media->get($stored->identifier());
if (! $storedMedia) {
return;
}
$image
->src($storedMedia->getTemporaryUrl(now()->addMinutes(5)))
->attribute('srcset', null);
})
->toHtml();The application must authorize the media for the current model, tenant, and viewer before emitting the URL. Removing srcset prevents public responsive URLs from remaining beside the private src.
The signed reference contains the application identifier and optional metadata, not the temporary upload token. Authorize that identifier in the replaceImagesUsing() callback before rendering a URL.
Stored Image Reference Versions
The data-inertia-forms-* namespace is package-owned. RichTextUploads removes client-submitted package attributes before storage callbacks run.
RichTextImage::identifier() writes a signed version 2 reference using the application key. Verification also checks app.previous_keys, so key rotation keeps existing content readable. keepTokenized() preserves only verified existing references or references issued by the storage callback.
A signature proves that the package issued a reference, not that the current user owns its image. Continue to authorize identifiers in the render callback.
Require a valid signature while rendering with requireSignedImages():
$html = RichTextContent::from($post->body)
->requireSignedImages()
->replaceImagesUsing(fn (RichTextStoredImage $stored, RichTextImage $image): RichTextImage => $image
->src(route('content-images.show', $stored->identifier())))
->toHtml();Versions before 1.4 wrote unsigned version 1 references. Version 1 references remain readable from existing stored HTML. They are read-only legacy data: RichTextUploads never trusts an unsigned version 1 reference submitted by the browser. Signed version 1 references remain valid, and all newly issued references use signed version 2.
Continue to sanitize untrusted HTML before storing or rendering it. Image upload validation does not sanitize the RichText HTML. Pre-processing sanitizers should allow src, alt, title, width, height, and data-inertia-forms-upload. Tokenized content also needs data-inertia-forms-image until RichTextContent renders it.
Word and Character Count
Word Count
RichText::make('content')->showWordCount();Character Count
RichText::make('content')->showCharacterCount();Max Length
Set a character limit for the editor text:
RichText::make('content')
->maxLength(5000)
->showCharacterCount();maxLength() is serialized to the editor and configures Tiptap's CharacterCount extension. It limits the visible text the editor counts, and showCharacterCount() makes that count visible to the user.
It does not add a Laravel validation rule by itself. RichText submits an HTML string, or null for an empty editor, so Laravel string length rules measure the submitted markup as well as the visible text. Add your own server-side rules for the submitted value, sanitize untrusted HTML before storing or rendering it, and use a custom rule or FormRequest hook if the server must enforce a visible-text limit:
RichText::make('content')
->maxLength(5000)
->showCharacterCount()
->rules(['nullable', 'string']);$request->validate([
'content' => ['nullable', 'string'],
]);
$html = $request->string('content')->toString();
// Sanitize the HTML with your application's approved sanitizer before storing.
// Count text extracted from sanitized HTML when enforcing a visible-text limit.Article Editor Configuration
Use placeholder() to set the empty-editor prompt:
RichText::make('body')
->label('Article Body')
->placeholder('Start writing...')
->full()
->linkAllowedProtocols(['https', 'mailto'])
->linkDefaultProtocol('https')
->linkTargetBlank()
->linkNoopener()
->minHeight(300)
->maxHeight(800)
->showWordCount()
->showCharacterCount()
->maxLength(10000)
->required();RichText-specific methods above compose with shared field APIs. Use Form Class, Model Binding, Validation, Conditional Visibility, Authorization, and Styling for labels, help, defaults, rules, visibility, authorization, layout, and classes. RichText image uploads use the same runtime configuration described in File Uploads.
Using the Component Directly
You may render RichText from each stack's richtext subpath with local HTML state, or pass serialized field props during manual form rendering.
<script setup lang="ts">
import { ref } from 'vue'
import {
RichText,
type RichTextLinkOptions,
type RichTextToolbar,
} from '@inertiaui/form-vue/richtext'
const body = ref<string | null>('<p>Draft body</p>')
const locked = ref(false)
const hasError = ref(false)
const toolbar: RichTextToolbar = [
'bold',
'italic',
'|',
'bulletList',
'orderedList',
'|',
'link',
'image',
'|',
'undo',
'redo',
]
const linkOptions: RichTextLinkOptions = {
targetBlank: true,
noopener: true,
noreferrer: true,
rel: ['nofollow'],
allowedProtocols: ['https', 'mailto'],
defaultProtocol: 'https',
autoLink: true,
linkOnPaste: true,
allowTargetOverride: false,
}
const editorParts = {
toolbar: 'border-b border-zinc-200',
toolbarButton: 'data-[active=true]:text-blue-600',
editor: 'min-h-48',
footer: 'text-xs',
dialog: 'max-w-lg',
}
</script>
<template>
<RichText
v-model="body"
id="article-body"
name="body"
label="Article body"
help="Draft the public article body."
placeholder="Start writing..."
:toolbar="toolbar"
:min-height="300"
:max-height="800"
show-word-count
show-character-count
:max-length="10000"
:link-options="linkOptions"
required
precognitive
:disabled="locked"
:invalid="hasError"
:error="hasError ? 'Article body is required.' : null"
badge="Draft"
badge-class="bg-zinc-100 text-zinc-700"
label-trailing="Required"
label-trailing-class="text-zinc-500"
tooltip="Shown on the article page."
help-position="below"
layout="stacked"
control-position="end"
label-class="font-medium"
help-class="text-zinc-500"
error-class="text-red-600"
wrapper-class="max-w-3xl"
control-class="bg-white"
class="font-medium"
:part-classes="editorParts"
/>
</template>import { useState } from 'react'
import {
RichText,
type RichTextLinkOptions,
type RichTextToolbar,
} from '@inertiaui/form-react/richtext'
const toolbar: RichTextToolbar = [
'bold',
'italic',
'|',
'bulletList',
'orderedList',
'|',
'link',
'image',
'|',
'undo',
'redo',
]
const linkOptions: RichTextLinkOptions = {
targetBlank: true,
noopener: true,
noreferrer: true,
rel: ['nofollow'],
allowedProtocols: ['https', 'mailto'],
defaultProtocol: 'https',
autoLink: true,
linkOnPaste: true,
allowTargetOverride: false,
}
const editorParts = {
toolbar: 'border-b border-zinc-200',
toolbarButton: 'data-[active=true]:text-blue-600',
editor: 'min-h-48',
footer: 'text-xs',
dialog: 'max-w-lg',
}
export default function ArticleEditor() {
const [body, setBody] = useState<string | null>('<p>Draft body</p>')
const [locked] = useState(false)
const [hasError] = useState(false)
return (
<RichText
value={body}
onValueChange={setBody}
id="article-body"
name="body"
label="Article body"
help="Draft the public article body."
placeholder="Start writing..."
toolbar={toolbar}
minHeight={300}
maxHeight={800}
showWordCount
showCharacterCount
maxLength={10000}
linkOptions={linkOptions}
required
precognitive
disabled={locked}
invalid={hasError}
error={hasError ? 'Article body is required.' : null}
badge="Draft"
badgeClass="bg-zinc-100 text-zinc-700"
labelTrailing="Required"
labelTrailingClass="text-zinc-500"
tooltip="Shown on the article page."
helpPosition="below"
layout="stacked"
controlPosition="end"
labelClass="font-medium"
helpClass="text-zinc-500"
errorClass="text-red-600"
wrapperClass="max-w-3xl"
controlClass="bg-white"
class="font-medium"
partClasses={editorParts}
/>
)
}RichText is a composite editor and does not forward arbitrary native attributes to an inner native element. See Native Attributes And Events. Image-upload runtime configuration uses File Uploads.
Editor Support Components
The richtext subpath also exports EditorShell, EditorToolbar, and EditorCountFooter with their prop types. These low-level presenters let custom RichText integrations reuse the package shell, toolbar semantics, count footer, native attributes, and styling hooks without pulling Tiptap into the root or components entry points.
| Component | Public options |
|---|---|
EditorShell | name writes data-editor-name; focused, invalid, and disabled select semantic states; classValue and controlClass extend shell classes. Vue provides toolbar, default, and footer slots. React renders children. Both forward native wrapper attributes. |
EditorToolbar | items is the normalized control/separator/spacer order; buttons supplies each control's label, state, and action; disabled disables every control; dataAttributePrefix changes generated test/data attribute names; classValue and buttonClass extend toolbar styling. React additionally receives an icons map; Vue toolbar buttons carry their icon components. Native wrapper attributes are forwarded. |
EditorCountFooter | footer.show controls rendering; wordLabel, characterLabel, and characterLimitLabel provide already-formatted display text; classValue extends footer styling. Both implementations forward native wrapper attributes. |
For broader presenter customization, copy the shipped RichText presenter and see Customizing Presenters.