nextjs-cms

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

OptionTypeDefaultDescription
placeholderstringnoneText shown in the empty editor.
minLengthnumber (≥ 0)noneMinimum length of the HTML string.
maxLengthnumber (> 0)noneMaximum length of the HTML string. Must be ≥ minLength.
sanitizebooleantrueClean the HTML on save (removes scripts, event handlers, unknown tags).
allowInlineColorsbooleantrueLet editors set text and background colors.
rtlbooleanlocale-basedForce the editor's direction.
allowMediabooleanfalseAllow iframe, video, audio embeds, and unlock the two options below.
allowedEmbedHostsstring[]YouTube, Vimeo, Dailymotion, SoundCloud, Spotify, Facebook, X/TwitterHosts allowed as iframe sources. Requires allowMedia: true.
allowImageUploadsobjectnoneSave 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, and data-*.
  • Allowed URL schemes: http(s), mailto, tel, ftp, file, and data: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 typed
Important:

sanitize: 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:

  1. Extracted from the HTML on save.
  2. Checked against maxFileSize (the save is rejected if too large).
  3. Resized to fit size and converted to format with sharp.
  4. Written to <uploads>/.photos/<section>/<name>.
  5. Replaced in the HTML by publicURL with {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-optionTypeDefaultDescription
publicURLURL string with {name}requiredThe 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.
omitExtensionbooleantrueSave 3fa9c... instead of 3fa9c....webp.

The three fit modes (same vocabulary as sharp):

fitBehaviorExtra 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.

Good to know:

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

ColumnLONGTEXT CHARACTER SET utf8mb4
ValueSanitized HTML string
Inline imagesFiles in <uploads>/.photos/<section>/, tracked in the editor_photos table