DEV Community

Cover image for Luthor: A WYSIWYG React Text Editor for Performance and Control
Rahul Anand
Rahul Anand

Posted on

Luthor: A WYSIWYG React Text Editor for Performance and Control

I built Luthor to end the editor tradeoff: fast setup with lock-in, or full control that burns weeks before you ship anything real at scale.

I just want to build my app! Not wonder what a toolbar icon should look like on the text editor.
I wanted one ecosystem where I can ship fast, then go deep only when I actually need to.

Quick Links

The components that make Luthor

The Core - @lyfie/luthor-headless

Use this when you want full editor UI control.

  • runtime dependencies: 0
  • core engine and related packages are exposed via peerDependencies
  • you choose exactly what to install and what to customize
  • you build your own UI/UX, toolbar, behavior, and design-system integration

This package is lean by design. Performant by intention.

The Showoff - @lyfie/luthor

Use this when you want one-package-solution, shipping speed with polished defaults, this package does not bloat your application, it depends ONLY on Lexical packages and The Core @lyfie/luthor-headless.

  • includes the Lexical package set it needs under the hood
  • gives you presets out of the box, just plug and play
  • includes and exposes all extensibility of @lyfie/luthor-headless under the hood
  • still lets you move into deeper customization when needed

This is the practical all-in-one lane.

QYSIQYG/WYSIWYG style, but without losing the escape hatch.

Performance at a glance

Both packages are built for fast, responsive editing in real apps.

  • @lyfie/luthor is intentionally super lightweight for a ready-to-ship WYSIWYG package.
  • @lyfie/luthor-headless is even more lightweight, since it keeps runtime dependencies at zero and relies on peer dependencies for composition.

Tree-Shaking

Tree-shaking helps keep shipped code lean:

  • headless is especially tree-shake friendly because it is ESM and marks sideEffects: false
  • preset package also tree-shakes JS imports, while stylesheet imports stay intentional

Result: you can avoid dragging unnecessary editor code into production bundles, keeping your final build as lean as possible on the users browser.

Features Developers Might Love (I know I do)

Writing + structure that feels solid

  • typography controls
  • formatting essentials
  • links, headings, alignment, and list workflows

Typography and formatting
Links and structure
Lists and structure

Productivity without friction

  • slash command center
  • command workflows
  • undo/redo and keyboard-first speed

Slash commands
History and shortcuts

Real product features, not just demo features

  • embeds (image, iframe, YouTube)
  • code blocks
  • custom blocks and extension-level flexibility
  • dark/light ready behavior

Embeds and media
Code blocks
Custom blocks
Theme support

Minimal Setup: Preset Lane

npm install @lyfie/luthor
Enter fullscreen mode Exit fullscreen mode
import { ExtensiveEditor } from "@lyfie/luthor";
import "@lyfie/luthor/styles.css";

export function App() {
  return <ExtensiveEditor placeholder="Start writing..." />;
}
Enter fullscreen mode Exit fullscreen mode

Minimal Setup: Headless Lane

npm install @lyfie/luthor-headless
Enter fullscreen mode Exit fullscreen mode
npm install lexical @lexical/code @lexical/link @lexical/list @lexical/markdown @lexical/react @lexical/rich-text @lexical/selection @lexical/table @lexical/utils
Enter fullscreen mode Exit fullscreen mode
import {
  createEditorSystem,
  RichText,
  richTextExtension,
  boldExtension,
  italicExtension,
} from "@lyfie/luthor-headless";

const extensions = [richTextExtension, boldExtension, italicExtension] as const;
const { Provider, useEditor } = createEditorSystem<typeof extensions>();

function Toolbar() {
  const { commands, activeStates } = useEditor();
  return (
    <div>
      <button onClick={() => commands.toggleBold?.()} aria-pressed={activeStates.bold === true}>
        Bold
      </button>
      <button onClick={() => commands.toggleItalic?.()} aria-pressed={activeStates.italic === true}>
        Italic
      </button>
    </div>
  );
}

export function App() {
  return (
    <Provider extensions={extensions}>
      <Toolbar />
      <RichText placeholder="Write here..." />
    </Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why Developers Stick With It

  • clear split between preset workflow and headless workflow
  • practical docs for getting started, presets, architecture, and API usage
  • live playground to evaluate flows before integration
  • feature depth that covers real product requirements, not just demos
  • developer-first approach: open source, MIT, no paywalls

Final Take

If you want a clean editor stack with zero confusion:

  • choose @lyfie/luthor-headless for maximum UI/UX control and lean dependency strategy
  • choose @lyfie/luthor for all the built in presets for fast QYSIQYG/WYSIWYG plug and play shipping and built-in ergonomics (bonus: you get luthor-headless features within this package too, best of both worlds)

Either way, you stay in one ecosystem and scale without redoing everything.

Quick Links (Again)

Top comments (2)

Collapse
 
claudiu_cimpoies profile image
Claudiu Cimpoies

Interesting approach.

While building my own blog recently I ran into a similar dilemma with rich-text editors. I initially experimented with Tiptap because it’s powerful and widely used in the React ecosystem, but I eventually realized that maintaining full control over the generated HTML and the editing pipeline became harder than expected.

In my case the blog uses a custom content structure and a rendering system, so predictable HTML output became more important than having a fully featured WYSIWYG editor.

That pushed me toward building a lightweight custom editor instead, mostly focused on controlled formatting and simpler debugging.

I'm curious — when designing Luthor, how did you approach the balance between flexibility and keeping the generated HTML predictable for real production use?

Collapse
 
rahulnsanand profile image
Rahul Anand

Totally relate, and that exact tradeoff is why I built Luthor.

I keep JSON as the source of truth and treat HTML/Markdown as bridge formats. For production cases where predictable HTML matters most, I use a constrained lane (HTMLEditor / LegacyRichEditor): HTML-native features only, metadata-heavy/custom nodes off by default, and strict mode-switch validation so invalid source throws a visible error instead of silently changing or malforming content.

If a team needs richer custom structures, they can move to the headless/extensive lane; metadata envelopes preserve non-native fields so round-trips stay intact. The goal was simple: reduce basic editor boilerplate code, keep output predictable, and still leave full flexibility when needed, all in an open-source, free stack.