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 (3)

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.

Collapse
 
claudiu_cimpoies profile image
Claudiu Cimpoies •

That’s actually a really interesting approach.

Using JSON as the source of truth and treating HTML/Markdown as bridge formats makes a lot of sense, especially for keeping structure consistent.

I also like the idea of strict validation when switching modes instead of silently fixing invalid content — that’s exactly where many editors introduce subtle bugs.

The “constrained lane vs headless lane” concept sounds like a smart way to balance predictability with flexibility. I’ll take a look at Luthor, sounds like a thoughtful design.