f-ui
Components

Rich Text

Safe static rendering for TipTap HTML and ProseMirror JSON documents.

Plus Registry

This component ships from registry.plus.json, not the public registry.json. Set up @f-ui-plus on the Installation page, then install @f-ui-plus/rich-text (pulls rich-text-internals).

Rich Text is the read surface for documents authored with Rich Text Editor. It renders TipTap HTML or ProseMirror JSON through one sanitize-then-render pipeline — no raw dangerouslySetInnerHTML. Pair it with Code Block when you want highlighted fences via a trusted renderers.codeBlock.

Rich Text renders TipTap HTML or ProseMirror JSON. Use Markdown Renderer when Markdown source is your canonical content format.

When To Use

  • Display HTML or JSON produced by Rich Text Editor (or an equivalent TipTap schema) in detail views, table cells, and read-pretty forms.
  • You need a closed sanitizer for untrusted HTML before it becomes React nodes.
  • Prefer JSON + schema version when the stored document must evolve with custom nodes.
  • Use Markdown Renderer instead when the canonical source is Markdown, not TipTap HTML/JSON.

Features

AreaBehavior
Formatsformat="html" (default) or format="json"
PipelineParse → sanitize (closed schema) → React; JSON goes through TipTap static render first
EmptySemantically empty values render Empty Value Placeholder
Fail-closedUnknown nodes / schema mismatch → fallback + optional onRenderError
Code fencesDefault <pre><code>; override with trusted renderers.codeBlock
TypographyScoped fui-rich-text styles from rich-text-internals (no Tailwind Typography plugin required)

Installing

Configure @f-ui-plus and FUI_PLUS_REGISTRY_TOKEN as in Installation — Plus Registry.

FUI_PLUS_REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-plus/rich-text
FUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/rich-text
FUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/rich-text
FUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/rich-text

registryDependencies: @f-ui-plus/rich-text-internals, empty-value-placeholder. Runtime: @tiptap/static-renderer, rehype-parse, rehype-react, rehype-sanitize, unified.

Usage

import { RichText } from "@/components/f-ui/rich-text/rich-text";

export function Page() {
  return <RichText value="<p>Hello <strong>world</strong></p>" />;
}

Examples

HTML

Trusted sample HTML with headings, lists, links, and a blockquote.

Product update

Ship notes with bold, emphasis, and a trusted link.

  • Paragraphs and lists
  • Headings 1–3

Trusted HTML only — sanitize on the server too.

"use client";

import { RichText } from "@/components/f-ui/rich-text/rich-text";

const SAMPLE_HTML = [
  "<h2>Product update</h2>",
  "<p>Ship notes with <strong>bold</strong>, <em>emphasis</em>, and a ",
  '<a href="https://example.com">trusted link</a>.</p>',
  "<ul><li>Paragraphs and lists</li><li>Headings 1–3</li></ul>",
  "<blockquote><p>Trusted HTML only — sanitize on the server too.</p></blockquote>",
].join("");

export function RichTextDemo() {
  return <RichText value={SAMPLE_HTML} />;
}

JSON

Toggle valid ProseMirror JSON, the canonical empty document, and an unknown-node payload. Invalid documents show fallback.

Structured body

Valid ProseMirror JSON with bold marks.

"use client";

import { useState } from "react";

import type { JSONContent } from "@/components/f-ui/rich-text-internals/rich-text-types";
import { EMPTY_RICH_TEXT_JSON } from "@/components/f-ui/rich-text-internals/rich-text-schema";
import { RichText } from "@/components/f-ui/rich-text/rich-text";
import { Button } from "@/components/ui/button";

const VALID_JSON: JSONContent = {
  type: "doc",
  content: [
    {
      type: "heading",
      attrs: { level: 2 },
      content: [{ type: "text", text: "Structured body" }],
    },
    {
      type: "paragraph",
      content: [
        { type: "text", text: "Valid ProseMirror JSON with " },
        { type: "text", text: "bold", marks: [{ type: "bold" }] },
        { type: "text", text: " marks." },
      ],
    },
  ],
};

const UNKNOWN_NODE_JSON: JSONContent = {
  type: "doc",
  content: [{ type: "unknownNode" }],
};

type Sample = "valid" | "empty" | "unknown";

const SAMPLES: Record<Sample, JSONContent> = {
  valid: VALID_JSON,
  empty: EMPTY_RICH_TEXT_JSON,
  unknown: UNKNOWN_NODE_JSON,
};

export function RichTextJsonDemo() {
  const [sample, setSample] = useState<Sample>("valid");

  return (
    <div className="space-y-4">
      <div className="flex flex-wrap gap-2">
        <Button
          type="button"
          variant={sample === "valid" ? "default" : "outline"}
          onClick={() => setSample("valid")}
        >
          Valid JSON
        </Button>
        <Button
          type="button"
          variant={sample === "empty" ? "default" : "outline"}
          onClick={() => setSample("empty")}
        >
          Empty document
        </Button>
        <Button
          type="button"
          variant={sample === "unknown" ? "default" : "outline"}
          onClick={() => setSample("unknown")}
        >
          Unknown node
        </Button>
      </div>
      <RichText
        format="json"
        value={SAMPLES[sample]}
        fallback={<span>Unavailable — document failed to render</span>}
      />
    </div>
  );
}

Sanitization

Hostile markup with <script>, event attributes, a javascript: link, and id / name. Client filtering is defense in depth — sanitize again on the server before persistence or publication.

Client sanitization strips scripts, event handlers, javascript: links, and id/name. Server sanitization is still required before persistence or publication.

Looks safe

javascript link

Visible text remains after client sanitization.

"use client";

import { RichText } from "@/components/f-ui/rich-text/rich-text";

const HOSTILE_HTML = [
  '<p id="clobber" name="clobber" onclick="alert(1)">Looks safe</p>',
  "<script>document.body.innerHTML='owned'</script>",
  '<a href="javascript:alert(1)">javascript link</a>',
  "<p>Visible text remains after client sanitization.</p>",
].join("");

export function RichTextSanitizationDemo() {
  return (
    <div className="space-y-3">
      <p className="text-muted-foreground text-sm">
        Client sanitization strips scripts, event handlers,{" "}
        <code>javascript:</code> links, and <code>id</code>/<code>name</code>.
        Server sanitization is still required before persistence or publication.
      </p>
      <RichText value={HOSTILE_HTML} />
    </div>
  );
}

Code Block

Map sanitized fences to Plus Code Block through a trusted renderer.

Trusted code fences can map to Plus Code Block:

const greeting = "hello";
console.log(greeting);
"use client";

import { CodeBlock } from "@/components/f-ui/code-block/code-block";
import { RichText } from "@/components/f-ui/rich-text/rich-text";

const SAMPLE_HTML = [
  "<p>Trusted code fences can map to Plus Code Block:</p>",
  '<pre><code class="language-ts">const greeting = "hello";\nconsole.log(greeting);</code></pre>',
].join("");

export function RichTextCodeBlockDemo() {
  return (
    <RichText
      value={SAMPLE_HTML}
      renderers={{
        codeBlock: ({ source, language }) => (
          <CodeBlock source={source} language={language} />
        ),
      }}
    />
  );
}

Composition

RichText
└── renderRichText
    ├── HTML → rehype-parse → sanitize → rehype-react
    └── JSON → TipTap static renderer → same sanitize path
        └── optional renderers.codeBlock → CodeBlock

Security

ThreatDefault
<script> / media / forms / SVGRemoved by the closed sanitizer schema
Event-handler attributesStripped
javascript: and exotic link schemesBlocked; link may remain without a dangerous href
id / name clobberingRemoved
Unknown JSON nodesFail closed → fallback
Custom renderersTrusted app code; you own the security trade-off

Client sanitization is not a server security boundary. Validate schema and sanitize HTML on the backend before storage or public rendering.

Edge Cases & Errors

Empty Documents

Semantically empty HTML ("") and EMPTY_RICH_TEXT_JSON render the empty placeholder. Pass emptyText to customize the message.

Invalid JSON / Schema Mismatch

Unknown nodes or an unsupported schemaVersion call onRenderError (when provided) and render fallback instead of throwing into the tree.

API Reference

Props

PropTypeDefaultDescription
valuestring | JSONContent | null | undefined(required)Document to render.
format"html" | "json""html"Value format.
schemaVersionnumberWhen set, must match RICH_TEXT_SCHEMA_VERSION.
fallbackReactNodenullShown when rendering fails closed.
emptyTextReactNodePassed to Empty Value Placeholder when empty.
renderersRichTextRendererMapTrusted overrides (e.g. codeBlock).
extensionConfigRichTextExtensionConfigShared extension bundle for custom schema parity.
onRenderError(error: Error) => voidOptional logging hook for fail-closed paths.
classNamestringMerged onto the root.
classNamesPartial<Record<RichTextSlot, string>>Per-slot styling.

Slots

SlotApplied to
rootOuter wrapper
contentInner fui-rich-text prose region

On this page