richTextField
A WYSIWYG editor that stores sanitized HTML. Optional embedded media and inline image uploads.
import { richTextField } from 'nextjs-cms/core/fields'Basic usage
richTextField({ name: 'body' })Editors get a rich-text editor with headings, lists, links, tables, and formatting. The HTML is sanitized on save and stored in a LONGTEXT column.
With every option
richTextField({
name: 'body',
label: 'Article body',
required: true,
localized: true,
order: 4,
placeholder: 'Start writing…',
minLength: 200,
maxLength: 200_000,
sanitize: true,
allowInlineColors: false,
rtl: false,
// Media: only valid with allowMedia: true
allowMedia: true,
allowedEmbedHosts: ['youtube.com', 'vimeo.com', 'player.acme-video.com'],
allowImageUploads: {
publicURL: 'https://acme.com/uploads/editor/{name}',
size: { width: 1400, height: 1400, fit: 'inside' },
maxFileSize: { size: 2, unit: 'mb' },
format: 'webp',
omitExtension: true,
},
})Options
| Option | Type | Default | Description |
|---|---|---|---|
placeholder | string | none | Text shown in the empty editor. |
minLength | number (≥ 0) | none | Minimum length of the HTML string. |
maxLength | number (> 0) | none | Maximum length of the HTML string. Must be ≥ minLength. |
sanitize | boolean | true | Clean the HTML on save (removes scripts, event handlers, unknown tags). |
allowInlineColors | boolean | true | Let editors set text and background colors. |
rtl | boolean | locale-based | Force the editor's direction. |
allowMedia | boolean | false | Allow iframe, video, audio embeds, and unlock the two options below. |
allowedEmbedHosts | string[] | YouTube, Vimeo, Dailymotion, SoundCloud, Spotify, Facebook, X/Twitter | Hosts allowed as iframe sources. Requires allowMedia: true. |
allowImageUploads | object | none | Save pasted/dropped images as files instead of base64. Requires allowMedia: true. |
Plus the common options.
Options in detail
sanitize
On by default, and you should keep it on. Before saving, the HTML is run through an allow-list sanitizer:
- Kept tags: headings
h1–h6,p,br,hr,div,span,strong/b,em/i,u,s/strike,sub,sup,small,mark,code,pre,blockquote,a,img,figure,figcaption, lists (ul,ol,li,dl,dt,dd), and tables. - Kept attributes:
href,src,alt,title,target,rel,class,id,name,style,width,height,align, anddata-*. - Allowed URL schemes:
http(s),mailto,tel,ftp,file, anddata:image/...;base64. - Removed:
<script>,<style>, event handlers (onclick, ...),javascript:URLs, and anything not on the lists.
richTextField({ name: 'embed_code', sanitize: false }) // stored exactly as typedsanitize: false stores whatever the editor submits, scripts included. Only use it for trusted admins, and never render such a field on a page that also shows user data.
allowInlineColors
Editors love coloring text; designers don't. Set it to false and the color pickers disappear from the toolbar, and the sanitizer strips color and background-color from inline styles, so pasted content can't sneak colors in either:
richTextField({ name: 'body', allowInlineColors: false })Alignment, font size, and image sizing styles are unaffected. Only has an effect while sanitize is on.
allowMedia and allowedEmbedHosts
allowMedia: true enables the media/embed toolbar buttons and lets iframe, video, audio, source and track tags through the sanitizer.
Iframes run third-party code, so they're only kept when their src host is allowed. Hosts match exactly or as a subdomain (player.vimeo.com matches vimeo.com), never as a prefix (youtube.com.evil.example doesn't match).
// Default hosts: YouTube, Vimeo, Dailymotion, SoundCloud, Spotify, Facebook, X/Twitter
richTextField({ name: 'body', allowMedia: true })
// Only your own video platform
richTextField({
name: 'body',
allowMedia: true,
allowedEmbedHosts: ['video.acme.com'],
})allowedEmbedHosts replaces the defaults. Add them back if you still want YouTube: ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'video.acme.com'].
allowImageUploads
Without it, images pasted or dropped into the editor are stored inside the HTML as base64 data: URLs. That works, but it bloats the column and can't be cached by a CDN. With allowImageUploads, each inline image is:
- Extracted from the HTML on save.
- Checked against
maxFileSize(the save is rejected if too large). - Resized to fit
sizeand converted toformatwith sharp. - Written to
<uploads>/.photos/<section>/<name>. - Replaced in the HTML by
publicURLwith{name}substituted.
richTextField({
name: 'body',
allowMedia: true,
allowImageUploads: {
// Required. {name} is replaced by the generated file name.
publicURL: 'https://cdn.acme.com/.photos/posts/{name}',
},
})| Sub-option | Type | Default | Description |
|---|---|---|---|
publicURL | URL string with {name} | required | The URL your website serves the file from. {name} can be anywhere: https://site.com/img/{name} or https://site.com/api/image?file={name}. |
size | { width, height, fit } | { width: 1200, height: 675, fit: 'inside' } | Box each image is fitted into. |
maxFileSize | { size, unit: 'kb' | 'mb' } | { size: 1, unit: 'mb' } | Largest accepted image, measured on the decoded bytes. |
format | 'webp' | 'jpg' | 'jpeg' | 'png' | 'webp' | Output format. |
omitExtension | boolean | true | Save 3fa9c... instead of 3fa9c....webp. |
The three fit modes (same vocabulary as sharp):
fit | Behavior | Extra option |
|---|---|---|
'inside' | Scale down to fit within the box, keeping the aspect ratio. Never enlarges. | none |
'cover' | Fill the box exactly, cropping the overflow. | none |
'contain' | Fit inside and pad the rest with a background color. | background: { r, g, b, alpha } (falls back to media.images.background) |
allowImageUploads: {
publicURL: 'https://acme.com/media/{name}',
size: { width: 800, height: 800, fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 1 } },
format: 'jpg', // jpeg has no transparency: use an opaque background with 'contain'
}When an item is edited and an inline image is removed from the HTML, its file is deleted. Deleting the item deletes all its inline images.
publicURL points at your website, not the admin. Serve the uploads folder there (or sync it to a CDN). See Media & Uploads.
minLength / maxLength
Measured on the stored HTML, tags included. <p>Hi</p> has length 9. Use them as coarse guards ("not empty", "not absurdly long"), not as a word count.
Examples
A blog body with embeds and uploaded images
richTextField({
name: 'content',
required: true,
localized: true,
allowMedia: true,
allowInlineColors: false,
allowImageUploads: {
publicURL: `${process.env.NEXT_PUBLIC_SITE_URL}/media/.photos/posts/{name}`,
size: { width: 1600, height: 1600, fit: 'inside' },
maxFileSize: { size: 3, unit: 'mb' },
},
})A short formatted note, no media
richTextField({ name: 'announcement', maxLength: 2000, allowInlineColors: false })Rendering on your website
The stored value is HTML. Render it with dangerouslySetInnerHTML. It was sanitized on save, but sanitizing again on output costs little if you want defense in depth:
export function Prose({ html }: { html: string }) {
return <div className='prose' dangerouslySetInnerHTML={{ __html: html }} />
}Storage
| Column | LONGTEXT CHARACTER SET utf8mb4 |
| Value | Sanitized HTML string |
| Inline images | Files in <uploads>/.photos/<section>/, tracked in the editor_photos table |