In 2025, when our small team began building LixBlogs, we did not set out to create an editor package.
We wanted to create a blogging platform. The editor was supposed to be one part of it: a place to write a title, compose a few paragraphs, add an image, and press Publish.
Then the editor kept growing.
Writers wanted Markdown shortcuts without giving up a visual editing experience. Technical authors needed syntax-highlighted code, equations, and diagrams. Images needed upload states rather than a frozen page. The published version needed to render the same document without mounting an editable surface. Later, collaboration meant the editor also had to accept shared Yjs state instead of always owning its initial content.
At some point, we were no longer adding features to a text box. We were designing a document system.
That was when we asked a different question:
If this editing layer is useful to us, why should another React application have to rebuild it—or adopt all of LixBlogs—to get the same foundation?
This is the implementation story behind @elixpo/lixeditor.
First, we had to find the real boundary
Extracting an npm package is easy if “extracting” means copying a folder and adding a package.json. It is much harder when the copied code still assumes the host has your authentication, storage endpoint, theme provider, routes, CSS, and database.
Our first useful decision was to divide responsibilities by ownership.
flowchart LR
subgraph Host[Host application owns]
Auth[Authentication]
Save[Saving and versioning]
Media[Media storage]
Routes[Routing and publishing]
Product[Product-specific blocks]
end
subgraph Package[LixEditor owns]
Schema[Configurable document schema]
Editor[Editing surface]
Preview[Reader preview]
Theme[Theme contract]
Output[Blocks, HTML and Markdown output]
end
Auth --> Editor
Media --> Editor
Product --> Schema
Editor --> Save
Editor --> Output
Output --> Routes
Schema --> Editor
Schema --> Preview
Theme --> Editor
Theme --> PreviewThe host application should decide who may edit, where a file is uploaded, when a document is saved, and what publishing means. The package should understand how a document is edited and rendered.
That sounds obvious after it is written down. It was not obvious while those responsibilities were intertwined inside a working product.
The schema became the centre of the package
LixEditor is built on BlockNote. Instead of treating the toolbar or the React component as the core abstraction, we treat the document schema as the centre.
The editor begins with BlockNote’s default block and inline-content specifications. It then assembles a schema from the features enabled by the host:
const blockSpecs = { ...defaultBlockSpecs };
if (features.codeHighlighting) blockSpecs.codeBlock = codeBlock;
if (features.equations) blockSpecs.blockEquation = BlockEquation({});
if (features.mermaid) blockSpecs.mermaidBlock = MermaidBlock({});
if (features.tableOfContents) {
blockSpecs.tableOfContents = TableOfContents({});
}
if (features.images) blockSpecs.image = ImageBlock({});
for (const item of extraBlockSpecs) {
if (item.type && item.spec) blockSpecs[item.type] = item.spec;
}
const schema = BlockNoteSchema.create({
blockSpecs,
inlineContentSpecs,
});This gives us two kinds of extension.
The first is controlled configuration: a consumer can disable equations, Mermaid, images, PDFs, buttons, dates, link previews, or other optional behaviour it does not need.
The second is open extension: a consumer can register additional block specifications, inline specifications, and slash-menu actions without maintaining a fork of the package.
The schema is intentionally stable for the life of an editor mount. Feature and schema decisions should therefore be made before mounting the editor; changing the schema beneath a live document is a migration problem, not a harmless UI toggle.
One document, two very different jobs
An editable document and a published document may contain the same information, but they do not have the same job.
The editing surface needs cursor behaviour, selection state, slash menus, table handles, uploads, and collaboration. The reader needs stable HTML, accessible output, code highlighting, rendered mathematics, and responsive diagrams. Shipping the full editor just to display a post would be wasteful and would blur an important performance boundary.
So the package exposes two main components:
import {
LixEditor,
LixPreview,
LixThemeProvider,
} from '@elixpo/lixeditor';LixEditor owns the interactive BlockNote experience. LixPreview receives document blocks and renders the reader-facing form.
flowchart TD
B[BlockNote document blocks] --> E[LixEditor]
E -->|getBlocks| B
E -->|getMarkdown| M[Markdown export]
E -->|getHTML| H[Deterministic HTML]
B --> R[renderBlocksToHTML]
R --> P[LixPreview]
P --> K[KaTeX equations]
P --> D[Mermaid diagrams]
P --> S[Shiki code highlighting]
P --> L[Link previews]The HTML renderer walks the block tree itself. It preserves headings, nested lists, checklists, alignment, equations, code blocks, images, tables, buttons, and a generated table of contents. Custom content that needs a richer browser renderer is represented with data attributes and enhanced by LixPreview after insertion.
KaTeX, Mermaid, and Shiki are loaded dynamically when the preview actually contains something they need to process. A simple article should not pay the same runtime cost as a document containing three programming languages and a sequence diagram.
Markdown export is also available, but it is important to describe it accurately. The canonical document is a structured BlockNote block tree. Markdown is an output format and an input convenience; some richer block semantics cannot round-trip through plain Markdown without loss.
Host-owned uploads were a non-negotiable boundary
An editor package should not silently choose somebody else’s media architecture.
One application may upload to Cloudinary, another to S3-compatible storage, and another may insert a signed internal asset URL. Authentication, quotas, compression, deletion, and retry policy all belong to the host.
LixEditor therefore accepts an uploadFile function:
async function uploadImage(file) {
const body = new FormData();
body.append('file', file);
const response = await fetch('/api/media/upload', {
method: 'POST',
body,
});
if (!response.ok) throw new Error('Upload failed');
const result = await response.json();
return result.url;
}
<LixEditor
uploadFile={uploadImage}
acceptImageTypes={['image/png', 'image/jpeg', 'image/webp']}
maxFileSizeBytes={8 * 1024 * 1024}
onUploadError={(error) => reportUploadFailure(error)}
/>Inside the editor, paste, drop, and file selection follow the same lifecycle.
sequenceDiagram
actor Writer
participant Editor as LixEditor
participant Host as Host uploadFile()
participant API as Media API
participant Store as Object storage
Writer->>Editor: Paste or select an image
Editor->>Editor: Insert uploading placeholder
Editor->>Host: uploadFile(file)
Host->>API: Authenticated upload request
API->>Store: Compress/store according to host policy
Store-->>API: Durable media URL
API-->>Host: URL
Host-->>Editor: Resolve URL
Editor->>Editor: Replace placeholder with image block
alt Upload fails
Host-->>Editor: Reject with an error
Editor->>Editor: Preserve block and show failure state
endThis separation lets LixBlogs maintain its own persistent upload queue and storage accounting without forcing those systems into every package consumer. In a zero-configuration standalone use, the editor can fall back to a data URL, but production hosts should provide an uploader appropriate to their storage model.
Collaboration is injected, not assumed
BlockNote can use Yjs collaboration state. The package accepts that collaboration configuration rather than opening its own WebSocket or inventing an account model.
<LixEditor
collaboration={{
provider,
fragment,
user: {
name: currentUser.displayName,
color: currentUser.cursorColor,
},
}}
/>When collaboration is present, it becomes the document source. Without it, initialContent seeds a normal local editor.
That distinction is important. A reusable editor should not decide how a collaboration token is issued, how room access is authorized, how many users may join, or how reconnects are surfaced. LixBlogs has product rules around those questions; another application will have different rules.
The package only needs a valid shared-document configuration and a clear contract.
flowchart LR
Identity[Host identity and permissions] --> Token[Host collaboration token]
Token --> Provider[Yjs provider]
Provider <--> Shared[Y.Doc / shared fragment]
Shared <--> Editor[LixEditor]
Editor --> Presence[Cursor and presence UI]We exposed document operations, not internal React state
Applications still need to save, publish, preview, or insert content from outside the editor. We use a forwarded ref to expose a small imperative surface:
const editorRef = useRef(null);
const blocks = editorRef.current.getBlocks();
const html = editorRef.current.getHTML();
const markdown = await editorRef.current.getMarkdown();
editorRef.current.insertImage(url, {
alt: 'Architecture diagram',
align: 'center',
});The host receives document-level operations. It does not need access to every internal hook or toolbar state.
There are two HTML paths for a reason. getHTMLLossy() delegates to the editor’s DOM-oriented conversion. getHTML() uses our deterministic block renderer, including special handling for richer blocks and email-safe buttons. The names make that difference explicit instead of pretending all HTML exports preserve the same semantics.
Packaging browser-heavy editor code has consequences
An npm package is also a distribution contract.
LixEditor’s build uses esbuild to produce both ESM and CommonJS entry points, source maps, and a bundled stylesheet. The source starts with a client boundary because the editor depends on browser APIs and interactive React state.
Large framework-level dependencies remain external:
external: [
'react',
'react-dom',
'@blocknote/core',
'@blocknote/react',
'@blocknote/mantine',
'katex',
'mermaid',
'shiki',
]This avoids bundling a second copy of React or BlockNote into every consuming application. React, React DOM, and the BlockNote/Mantine packages are declared as peer dependencies. KaTeX, Mermaid, and Shiki are package dependencies, but they are still externalized from the generated bundle and loaded by the features that use them.
CSS is shipped in two forms. Most consumers can import one bundled entry:
import '@elixpo/lixeditor/styles';Advanced consumers can import the preserved granular CSS files. The package also exposes CSS variables so a host can adapt the visual system without rewriting the editor’s components.
What remains specific to LixBlogs
Extraction did not mean pretending every part of our product was generic.
The production LixBlogs editor still keeps local integrations for author, blog, and organisation mentions; product-specific AI commands; subpages and canvas blocks; its persistent media queue; editorial outline behaviour; and publishing-state rules.
The npm package contains the portable editing and rendering foundation. LixBlogs contains the publishing product built around that foundation and, today, product-specific local implementations where the integration is deeper.
flowchart TB
Core[Portable LixEditor concepts and blocks]
Core --> Package["@elixpo/lixeditor"]
Core --> AppEditor[LixBlogs product editor]
Package --> Consumers[External React consumers]
AppEditor --> Mentions[Mentions and organisations]
AppEditor --> Queue[Persistent media queue]
AppEditor --> Publishing[Draft and publishing workflow]
AppEditor --> ProductBlocks[Product-specific blocks]This is not the cleanest possible final state. Shared fixes still need discipline while the package and product-specific editor evolve beside each other. Moving more portable behaviour behind the package boundary without forcing LixBlogs-only assumptions outward is ongoing work.
That honesty matters. “We made a package” is not the same as “we eliminated every duplicate or product-specific path.”
The smallest useful integration
A consumer can begin with an editor, a preview, and shared block state:
import { useRef, useState } from 'react';
import {
LixEditor,
LixPreview,
LixThemeProvider,
} from '@elixpo/lixeditor';
import '@blocknote/core/fonts/inter.css';
import '@blocknote/mantine/style.css';
import '@elixpo/lixeditor/styles';
export default function ArticleComposer() {
const editorRef = useRef(null);
const [blocks, setBlocks] = useState();
return (
<LixThemeProvider defaultTheme="light">
<LixEditor
ref={editorRef}
initialContent={blocks}
onChange={(editor) => setBlocks(editor.document)}
features={{
equations: true,
mermaid: true,
codeHighlighting: true,
linkPreview: false,
}}
/>
<LixPreview blocks={blocks} />
</LixThemeProvider>
);
}From there, the host can add its own upload handler, save strategy, custom blocks, collaboration provider, and publishing workflow.
The package provides an editing layer. It deliberately does not try to become a CMS in disguise.
What this extraction taught us
The most useful lesson was not about npm.
It was that reuse is a test of boundaries.
If a component cannot work without knowing the current user, storage vendor, API route, and page layout, it may still be useful—but it is not yet portable. If its output cannot be rendered without mounting the entire editing environment, the document model and interface have not really been separated. If extending it requires editing an internal switch statement, it is asking consumers to maintain a fork.
Creating @elixpo/lixeditor forced us to confront each of those assumptions.
We are still refining the boundary. There are behaviours that should move into the package, and others that should remain unapologetically specific to LixBlogs. The goal is not to turn every product component into a library. The goal is to recognize when a piece of infrastructure has become useful beyond the product that created it—and then give it a contract strong enough to travel.
Sometimes a product gives birth to a tool.
The interesting part is what that tool teaches you when it comes back home.

