SPA vs MPA: Why We Are Reemphasizing Server-Side Frameworks
23min read
|Before I was even born, people used to create rich text using typewriters. Unfortunately, this was extremely time-consuming. Later, in the early computer environment, the idea of connecting computers into a massive network began to take root: a network so advanced that it allowed messages to be sent without standing in lines, and thus without ever having to leave the house or interact with people. That network was the internet.
As the years went by, people began experimenting with documents that anyone connected to the internet could view. These documents had to be lightweight enough to load fast, but clear enough for anyone to understand. To keep them that way, people broke documents into parts, linked them together, and wrote special scripts that allowed them to be “brought to life.” Shortly thereafter, we stood on the threshold of a revolution sparked by MPA (Multiple Page App) applications, which transformed these linked and animated documents into fully-fledged online pizza order systems, bank websites, and e-commerce stores.
That history matters because the web is now swinging back toward server-side and multi-page application patterns, especially in frameworks that try to reduce JavaScript complexity. For developers integrating rich text editing into those environments, the question is no longer whether CKEditor works well in SPAs, but how to make it feel native in MPA and HTML-over-the-wire frameworks.
The hidden costs of the SPA architecture in large organizations
Single-page applications (SPAs) load one main page and update content dynamically in the browser, while multi-page applications (MPAs) serve separate, pre-rendered pages from the server. Unlike MPA applications, which typically send pre-rendered page content to the client as ready-made HTML files containing content and links, classic single page app (SPA) applications (without a server-side rendering backend) send scripts to the client that instruct the browser on how the page should look and where to fetch data for it. In other words, without executing the scripts and without the client fetching the content, an SPA page has no content that would be visible to web crawlers. This forces bots to use more computational power and crawl fewer of these pages compared to MPAs, which typically do not require running scripts to extract parseable content from them.
To address this, server-side rendering (SSR) and content hydration were introduced into applications. Using this approach, an SPA application renders HTML on the server side based on state X, and then sends it to the client along with that state X. Next, it iterates through the entire DOM tree and allows the specific framework to create an internal representation of the data on the page. This is time-consuming and prone to hydration errors, but it is search engine-friendly.
A major advantage (or disadvantage) of SPA applications is the need to use JavaScript (or TypeScript) as the primary language for building the site. This carries clear benefits, such as code sharing and the ease of hiring full-stack developers. Unfortunately, it also has its drawbacks. Node.js, the primary runtime environment for these applications, may not perform well in CPU-intensive tasks or in applications requiring real-time responses (such as games or chats with thousands of users) due to its suboptimal handling of multithreading. For this reason, the front-end and back-end of SPA applications are often hosted separately and use different frameworks.
In response to the growing popularity of SPA frameworks and JavaScript-powered page rendering, the creators of MPA frameworks didn’t want to be left behind. They designed solutions that introduced interactivity to pre-rendered HTML without computationally expensive content hydration or splitting the application into two parts. Frameworks were created that transmit HTML via sockets, among other methods, in response to user interactions. These frameworks don’t require advanced bundlers or a strong JavaScript knowledge to display even the simplest SSR-friendly button.
When the CKEditor lifecycle intersects with MPA and HTML-over-the-wire applications
In SPA applications, integrating WYSIWYG editors is generally straightforward. You can use a ready-made integration, or you can initialize the editor manually after installing the package via NPM or linking to the package from a CDN. When using package-based SPA integrations, this usually means working within the Node.js ecosystem and using NPM or another compatible package manager.
Unfortunately, things get more complicated in modern MPA frameworks, which rebel against the so-called JavaScript Fatigue, and take an approach that does not require installing any Node package management environment. This simplifies continuous integration (CI) as well as the project installation process due to the lack of requiring any additional environments. It also reduces the potential risk of breaches and the attack surface for supply chain attacks, as MPA framework packages are generally more static in the context of development.
The problem is that most libraries, including CKEditor, provide their packages as tree-shakable NPM modules, which may be in CJS, UMD, ESM, or other formats. This requires a Node.js environment just to install them. That’s not the end of the problems. Since most modern MPA frameworks actually prefer a “bundler-free” architecture, this means that the simplest await import( ‘ckeditor5’ ) will not be automatically resolved by JavaScript. You need to add such dependencies in import maps, which (depending on the framework) can be constructed during environment initialization or by simply manually adding a script tag. This can be a headache for framework users unaccustomed to working with frontend and JavaScript terminology, partly because not every package can be added via an import map (only ESMa packages are supported), and also because one must be aware of the existence of import maps themselves. Of course, the same applies to style sheets.
However, there are more challenges. Frameworks that handle HTML-over-the-wire inherently involve sending HTML and modifying the DOM based on user actions. It sounds good in theory, but in practice, this leads to accidental removal of content from the tags where the editor is mounted. This can be remedied by adding appropriate tags that prevent the framework from interfering with the JavaScript component (for example, in Phoenix, this is phx-hook=”ignore”). It’s also important to remember that you must handle the editor’s communication with the backend state of these frameworks yourself.
Fortunately, there are integrations that make this much easier.
CKEditor 5 in Blazor (ASP.NET)
Blazor is a prime example of a framework that combines the benefits of SPA applications with the simplicity typical of the ASP.NET ecosystem. However, every JavaScript component embedded in Blazor requires a certain amount of code to bridge the two sides: JavaScript interoperability via ElementReference and DotNetObjectReference. It also requires manual configuration of import maps for ESM modules and handling the component lifecycle in OnAfterRenderAsync. This is standard procedure for any external widget that needs to communicate with C#, and CKEditor is no exception.
Here’s what a standalone integration might look like:
<script type="importmap">
{
"imports": {
"ckeditor5": "https://cdn.ckeditor.com/ckeditor5/48.3.0/ckeditor5.js"
}
}
</script>
<div @ref="editorRef"></div>
<!-- And the C# code is down below -->
private ElementReference editorRef;
private string content = string.Empty;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync(
"initCKEditor",
editorRef,
DotNetObjectReference.Create(this)
);
}
}
[JSInvokable]
public async Task UpdateContent(string data)
{
content = data;
}
// wwwroot/js/ckeditorInterop.js
window.initCKEditor = async ( element, dotnetRef ) => {
const {
ClassicEditor,
Bold,
Italic,
Essentials,
Paragraph
} = await import("ckeditor5");
const editor = await ClassicEditor.create( {
attachTo: element,
plugins: [ Essentials, Paragraph, Bold, Italic ],
toolbar: [ 'bold', 'italic' ],
} );
editor.model.document.on( 'change:data', () => {
dotnetRef.invokeMethodAsync(
'UpdateContent',
editor.getData()
);
} );
};
Please note that the example above has some shortcomings:
-
It does not include two-way binding. In other words, Blazor will not be able to set the editor’s value.
-
The component has limited reusability because the configuration is hard-coded in the JavaScript interop code.
-
The component does not include a function to destroy the editor when the interop is destroyed. The editor can only be destroyed once it has been fully initialized.
-
Image uploads must be implemented manually by creating a custom adapter that communicates with Blazor.
Ready-to-use CKEditor 5 integration with Blazor
I created an unofficial package called ckeditor5-blazor, which eliminates these inconveniences. After adding the NuGet package and registering the service in Program.cs, the only decision is to choose between self-hosted mode (assets automatically downloaded to wwwroot during MSBuild compilation) and CDN.
The <CKE5Editor> component supports, among other things:
-
Bidirectional binding via
@bind-Valuewith theEditorValuetype, which natively supports multiroot configurations. -
Support for the
OnChange,OnFocus, andOnReadyevents. -
Support for image uploads using Blazor callbacks. Simply use
OnImageUploadand save the uploaded image to the backend or a storage bucket. -
The ability to pass custom plugins.
Installation:
dotnet add package CKEditor.Blazor
Usage:
@using CKEditor.Blazor.Components.Assets
@using CKEditor.Blazor.Components
@using CKEditor.Blazor.Model
<HeadContent>
<CKE5Assets />
</HeadContent>
<CKE5Editor
EditorType="EditorType.Classic"
Value="@("<p>Initial content</p>")"
EditableHeight="300"
@bind-Value="content" />
<CKE5Editor
EditorType="EditorType.Multiroot"
Value="@(new Dictionary<string, string>
{
["header"] = "<p>Header content</p>",
["content"] = "<p>Main content</p>",
["footer"] = "<p>Footer content</p>"
})">
<CKE5UIPart Name="toolbar" Class="mb-4" />
<CKE5Editable RootName="header" />
<CKE5Editable RootName="content" />
<CKE5Editable RootName="footer" />
</CKE5Editor>
Repo: https://github.com/Mati365/ckeditor5-blazor
CKEditor 5 in Phoenix LiveView (Elixir)
LiveView is a part of the Phoenix framework that enables diffing, removing, and updating page content based on messages and states from the backend. Thanks to the unique features of OTP, it can handle a massive number of users accessing the site while using a relatively small amount of resources, and at the same time, it uses a lightweight JavaScript wrapper that doesn’t burden clients’ devices. Like Blazor, it requires bridges to communicate with JavaScript. These are called “hooks.”
Here’s what a standalone integration might look like:
const CKEditorHook = {
async mounted() {
try {
const { ClassicEditor, Essentials, Paragraph, Bold, Italic, List } = await import('ckeditor5');
this.editor = await ClassicEditor.create({
attachTo: this.el,
plugins: [Essentials, Paragraph, Bold, Italic, List],
toolbar: [
'undo', 'redo', '|',
'bold', 'italic', '|',
'bulletedList', 'numberedList'
]
});
this.editor.model.document.on('change:data', () => {
this.el.value = this.editor.getData();
this.el.dispatchEvent(
new Event('input', { bubbles: true })
);
});
} catch (error) {
console.error(error);
}
},
destroyed() {
if (this.editor) {
this.editor.destroy();
}
}
};
It should be noted that the hook described above, much like the Blazor bridge, has several drawbacks:
-
It supports editor types that have a single
editableproperty. -
The configuration is hardcoded, which contributes to poor component reusability.
-
It lacks support for two-way binding with Phoenix and its model.
-
It does not support the editor’s
readyandfocus.
Ready-to-use CKEditor 5 integration with Phoenix
Another package I created is called ckeditor5-phoenix, and supports, among others:
-
Binding the
valueto both the entire editor and individual editables using thevalueattribute in components. -
Handling
change,focus, andreadyevents. -
Image upload support, along with an additional Phoenix controller.
-
The ability to register custom plugins.
-
Support for all editor types (decoupled, multiroot, classic, inline, and balloon).
Installation:
def deps do
[
{:ckeditor5_phoenix, "<latest version>"}
]
end
Usage examples:
// assets/js/app.js
import { Hooks } from 'ckeditor5_phoenix';
const liveSocket = new LiveSocket(
'/live',
Socket,
{ hooks: Hooks }
);
Assets are managed by the package in three modes:
Self-hosted via
mix ckeditor5.install(command downloads files locally).Self-hosted via npm install (NPM downloads
ckeditor5_phoenix).CDN (the
<.cke_cloud_assets />helper in the head).
Editor configuration resides in Elixir: presets are defined in config.exs, with full support for the toolbar, plugins, and translations. Synchronization with LiveView comes down to a standard handle_event:
defmodule MyAppWeb.PageHTML do
use CKEditor5
end
<%!-- CDN assets in <head> --%>
<.cke_cloud_assets />
<%!-- Classic editor with synchronization --%>
<.ckeditor
id="editor"
type="classic"
value={@content}
change_event
editable_height="300px" />
def handle_event("ckeditor5:change", %{"data" => data}, socket) do
{:noreply, assign(socket, content: data["main"])}
end
The package also supports multiroot: separate editable areas within a single editor instance with a shared toolbar, which is useful in page builders and complex CMS layouts:
<.ckeditor type="multiroot" />
<.cke_ui_part name="toolbar" />
<.cke_editable root="header" value="<h1>Header</h1>" />
<.cke_editable root="content" value="<p>Main content</p>" />
<.cke_editable root="sidebar" value="<p>Sidebar</p>" />
Repo: https://github.com/Mati365/ckeditor5-phoenix
CKEditor 5 in Livewire (Laravel)
Livewire handles state synchronization between the server and the browser in a way that works transparently for typical Blade components, but every JS widget managing its own DOM requires explicit exclusion from this mechanism. wire:ignore informs the framework not to touch a given part of the tree, but syncing the content back to the server falls on the developer: events, debouncing, and initialization after livewire:navigated are the standard boilerplate accompanying every such component. Developers may also need to configure import maps manually and exclude ESM dependencies from the bundler: more boilerplate that a ready-made integration normally handles for them.
Example:
<script type="importmap">
{
"imports": {
"ckeditor5": "https://cdn.ckeditor.com/ckeditor5/48.3.0/ckeditor5.js"
}
}
</script>
<div wire:ignore>
<div id="editor"></div>
</div>
<script>
document.addEventListener('livewire:navigated', async () => {
const { ClassicEditor, Bold, Italic, Essentials, Paragraph } = await import("ckeditor5");
const editor = await ClassicEditor.create({
attachTo: document.getElementById('editor'),
plugins: [ Essentials, Paragraph, Bold, Italic ],
toolbar: [ 'bold', 'italic' ],
});
let timer;
editor.model.document.on('change:data', () => {
clearTimeout(timer);
timer = setTimeout(() => {
Livewire.dispatch('editor-content-changed', { content: editor.getData() });
}, 250);
});
});
</script>
Ready-to-use CKEditor 5 integration with Livewire
The ckeditor5-livewire package turns this into a few lines. You can easily install with Composer and NPM, then add one import in app.js, and then the livewire component is ready to use. Content synchronization works through wire:model.live without any manual events, and the package itself protects the editor from unwanted Livewire interference in the DOM. The package also provides dedicated components for multiroot editing. Each editable area (<livewire:ckeditor5-editable>) can have its own wire:model, allowing granular synchronization of, for example, the article’s header and body independently. The toolbar (<livewire:ckeditor5-ui-part name="toolbar">) lives as a separate component outside the editable areas.
Installation:
composer require mati365/ckeditor5-livewire
Usage:
<x-ckeditor5-assets />
<livewire:ckeditor5
wire:model.live="content"
editableHeight="300px"
:saveDebounceMs="300" />
For multiroot:
<livewire:ckeditor5 editorId="my-editor" editorType="multiroot" />
<livewire:ckeditor5-ui-part name="toolbar" editorId="my-editor" />
<livewire:ckeditor5-editable
editorId="my-editor"
rootName="header"
wire:model.live="content.header" />
<livewire:ckeditor5-editable
editorId="my-editor"
rootName="body"
wire:model.live="content.body" />
Communication from the server to the editor happens via the set-editor-content event, and from the editor to the server via editor-content-changed, which can be handled with the #[On] attribute in the Livewire component:
#[On('editor-content-changed')]
public function onEditorContentChanged(string $editorId, array $content): void
{
if ($editorId === $this->editorId) {
$this->content = $content;
}
}
Repo: https://github.com/Mati365/ckeditor5-livewire
CKEditor 5 in Rails (Ruby)
Rails with Hotwire/Turbo treats navigation as swapping page fragments, which works without any additional configuration for typical ERB views. However, every JavaScript component with its own lifecycle must take care of itself: a Stimulus controller, careful handling of Turbo morphing (which can reset an already initialized widget), manual importmap configuration, and custom event handling. It’s the same set of responsibilities as with any other external widget in this ecosystem, with the difference that a rich text editor generates slightly more of them than a typical datepicker.
// app/javascript/controllers/ckeditor_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
async connect() {
const { ClassicEditor, Bold, Italic, Essentials, Paragraph } = await import("ckeditor5");
this.editor = await ClassicEditor.create({
attachTo: this.element,
plugins: [ Essentials, Paragraph, Bold, Italic ],
toolbar: [ 'bold', 'italic' ],
});
let timer;
this.editor.model.document.on('change:data', () => {
clearTimeout(timer);
timer = setTimeout(() => {
this.element.dispatchEvent(new CustomEvent("editor:change", {
detail: { data: this.editor.getData() }, bubbles: true
}));
}, 300);
});
}
disconnect() { this.editor?.destroy(); }
}
Ready-to-use CKEditor 5 integration with Rails
The ckeditor5-rails package is a gem that doesn’t require any bundler. It works natively with importmap-rails. The ckeditor5_assets helper registers all necessary imports in the importmap, handles automatic package downloading from a CDN (jsDelivr or unpkg), and can even automatically update the CKEditor version when security patches are released (automatic_upgrades). You don’t have to touch the assets — the gem knows what to load and when.
Editor configuration lives in Ruby, within an initializer or directly in the controller as a preset, meaning there is no JS file to maintain. Multiroot, toolbars, plugins, translations, form handling via the form builder, integration with Simple Form: everything is available from the Ruby level.
Installation:
gem 'ckeditor5'
Usage:
<% content_for :head do %>
<%= ckeditor5_assets %>
<% end %>
<%= ckeditor5_editor initial_data: "<p>Content</p>", editable_height: 300 %>
<%= form_for @post do |f| %>
<%= f.ckeditor5 :content, required: true %>
<% end %>
Multiroot with separate editable areas:
<%= ckeditor5_editor type: :multiroot do %>
<%= ckeditor5_toolbar %>
<%= ckeditor5_editable 'header', style: 'border: 1px solid #ccc;' do %>
<h1>Article header</h1>
<% end %>
<%= ckeditor5_editable 'content', style: 'border: 1px solid #ccc' %>
<% end %>
Configuration of presets, versions, and CDN resides in config/initializers/ckeditor5.rb:
CKEditor5::Rails.configure do
gpl
version '47.5.0'
automatic_upgrades
toolbar :undo, :redo, :|, :bold, :italic, :link
plugins :Bold, :Italic, :Link, :Essentials, :Paragraph, :Undo
end
Repo: https://github.com/Mati365/ckeditor5-rails
CKEditor 5 in Symfony (PHP)
Symfony with Twig is a classic MPA environment where every JS component requires its own asset ceremony: loading the appropriate scripts and styles on the right pages, configuring importmap or Webpack Encore, and, if the editor is to work in Symfony forms, writing a custom type extension. This isn’t specific to CKEditor, but with a complex text editor, the list of things to handle is simply longer than with simpler widgets.
<script type="importmap">
{
"imports": {
"ckeditor5": "https://cdn.ckeditor.com/ckeditor5/48.3.0/ckeditor5.js"
}
}
</script>
<form method="POST">
<div id="editor"></div>
<input type="hidden" id="content" name="post[content]">
<button type="submit">Save</button>
</form>
<script>
document.addEventListener('DOMContentLoaded', async () => {
const { ClassicEditor, Bold, Italic, Essentials, Paragraph } = await import("ckeditor5");
const editor = await ClassicEditor.create({
attachTo: document.getElementById('editor'),
plugins: [ Essentials, Paragraph, Bold, Italic ],
toolbar: [ 'bold', 'italic' ],
});
let timer;
editor.model.document.on('change:data', () => {
clearTimeout(timer);
timer = setTimeout(() => {
// synchronization with hidden form field
document.getElementById('content').value = editor.getData();
}, 300);
});
// alternatively: synchronization on submit
const form = document.querySelector('form');
form.addEventListener('submit', () => {
document.getElementById('content').value = editor.getData();
});
});
</script>
Ready-to-use CKEditor 5 integration with Symfony
The ckeditor5-symfony package provides a ready-made integration for Symfony 6.x+. It manages assets automatically (CDN or self-hosted), and the editor configuration resides in config/packages/ckeditor5.yaml. Integration with Symfony forms is native: the new CKEditor5Type field type allows you to use the editor as a standard form field without any additional JS code. It supports presets, translations, and multiroot.
After adding the package via Composer and configuring the bundle (or importmap), you just need to register the field type and use it in a form. The package itself takes care of loading assets, hydration, and protecting the DOM against Symfony’s interference.
Installation:
composer require mati365/ckeditor5-symfony
Usage:
# config/packages/ckeditor5.yaml
ckeditor5:
presets:
default:
editorType: classic
config:
plugins: [Essentials, Paragraph, Bold, Italic, Link]
toolbar: [bold, italic, link, '|', undo, redo]
// src/Form/PostType.php
use Mati365\CKEditor5Bundle\Form\CKEditor5Type;
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('content', CKEditor5Type::class, [
'preset' => 'default', // preset name from YAML
'label' => 'Article content',
'required' => true,
]);
}
{{ ckeditor5_assets() }}
{{ form_start(form) }}
{{ form_row(form.content) }}
{{ form_end(form) }}
Besides integration with Symfony forms, the ckeditor5-symfony package also provides simple Twig functions that allow you to insert the editor directly into a template, without any FormType and without a hidden input. This is an ideal solution for things like page builders, previews, CMS settings, or any place where you don’t want to tie the editor to a form entity.
{{ cke5_cloud_assets() }} {# CDN #}
{# or for self-hosted via AssetsMapper – no need to add anything #}
{{ cke5_editor(
content: '<p>Starting article content</p>',
editorType: 'classic',
editableHeight: 400,
preset: 'default' {# or the name of your preset from config/packages/ckeditor5.yaml #}
) }}
{{ cke5_editor(editorType: 'multiroot') }}
{{ cke5_ui_part('toolbar') }}
<div class="row">
<div class="col">
<h3>Header</h3>
{{ cke5_editable(
rootName: 'header',
content: '<h1>Page title</h1>',
class: 'border p-3'
) }}
</div>
<div class="col">
<h3>Main content</h3>
{{ cke5_editable(
rootName: 'content',
content: '<p>Main content here...</p>',
editableHeight: 350
) }}
</div>
</div>
Repo: https://github.com/Mati365/ckeditor5-symfony
What is the biggest challenge when integrating CKEditor into a multi page application?
Although CKEditor is distributed as a modern, tree-shakable NPM package, integrating it with frameworks outside the Node ecosystem can still pose a significant challenge.
Lazy loading of resources
CKEditor is distributed as either an ESM or a UMD package. This is quite important if you plan to extend it with custom plugins later on. For UMD packages, the global object for CKEditor is window.CKEDITOR, and that is where you should retrieve ClassicEditor or plugins. This poses certain challenges. Custom plugins that are not bundled assuming that window.CKEDITOR is the editor’s entry point (e.g., just import ckeditor5) will not work. You will also need to implement lazy loading for that script yourself.
A workaround for these issues has been the use of ESM bundles and import maps. These workarounds solve the problem of dynamic resource loading (import allows for returning a Promise), and this approach is transparent to dependencies, which can still import ckeditor5 using the import syntax.
How do I pass a custom configuration to the editor?
Depending on the framework, the preferred method of passing configuration may vary. In Ruby on Rails, this typically involves custom initializers using a Ruby-based DSL. For Symfony, these are standard YAML files, while for Phoenix, it’s a config entrypoint written in Elixir. It’s also worth noting that some frameworks, such as Blazor or Phoenix, allow you to create config objects at the point of use for a given component. However, in the vast majority of cases, including other integrations, the editor is typically configured globally.
One solution I’ve used in integrations is the use of “presets” - in other words, aliases for specific editor configurations. This allows loading only a specific set of plugins inside the editor, for example, on the article-writing page, and yet another set in the user comment section. Integrations, therefore, do not have a single, global configuration that cannot be customized, but rather many, and they are all configured in the same place. They can, of course, be extended or overridden, depending on the framework used.
Unfortunately, a major drawback of configuring the editor in the global backend configuration is the issue of specifying the DOM elements used to create configurations for more advanced plugins (e.g., indicating where a specific part of the AI plugin’s UI should appear). This can be solved by creating custom constructs in the config, e.g., { $ref: “#element” }, which are substituted just before the plugin is initialized.
Sample preset configuration for Symfony:
ckeditor5:
presets:
minimal:
editorType: classic
config:
toolbar: [bold, italic, link]
plugins: [Bold, Italic, Link, Essentials, Paragraph]
How do I submit custom plugins?
Let’s say we already have a custom editor component, such as a Blazor component, that can be configured directly within ASP.NET. One day, however, we need to add a custom JavaScript plugin to the editor that extends it with an additional button. How do we do this? It turns out this isn’t trivial at all, because the plugin must execute its JS code after the editor has loaded. In other words, class MyPlugin extends Plugin won’t be invoked until Plugin is imported from ckeditor5, and this can only happen after ckeditor5 has loaded (that is, before its first use).
The workaround I used in my integrations is to use the global plugin creator registry. In other words, a global map of functions that create plugins is created. It looks something like this:
EditorPluginsRegistry.the.register(‘MyPlugin’, () => {
const { MyPlugin } = await import( ‘ckeditor5’ );
return class MyPlugin extends Plugin { ... };
});
During editor initialization, the editor scans this map and loads custom plugins. Unfortunately, this has its drawbacks, as it isn’t as “compact” as the configuration in an ecosystem like React, where you can define a plugin in the same place where you use the editor. On the other hand, it’s fairly straightforward to understand and easy to integrate with, for example, importmaps.
How can I declaratively add more roots or UI elements to the editor?
CKEditor allows you to add more than one root to the editor. This is useful when you divide a document into sections or use it to store data such as page headers and footers.
Let’s assume we’re designing an integration for Blazor and want to use the following component structure:
<editor>
<editable name="A">
<editable name="B">
</editor>
The structure above should initialize the editable elements A and B and assign them to the editor. In this structure, it should be possible to dynamically remove or add editable elements (e.g., by toggling something in the UI).
Unfortunately, implementing this may not be trivial, even though it appears so at first glance, due to several issues:
-
The editor initializes asynchronously. In other words,
editableelements may appear on the page while the editor is still loading and must “wait” until it initializes before they can be added. -
The
editorelement is a synthetic element because a multi-root editor does not have a single main element. Instead, the editable elements serve as the main elements. This means that restricting the user to wrapping “editable” elements with the<editor>tag may limit them in terms of what the editor’s API offers, and it should be possible to defineeditableelements outside theeditorelement. -
The editor may restart after initialization using a watchdog (e.g., when the editor triggers something that causes it to crash).
My integrations work in a similar way to loading plugins by using a global editor registry. Each <editable> element can pass an editor ID, which is used to register a watcher that waits for the element to be fully initialized. However, there’s a catch. The watcher is not removed after it is initialized, forcing the developer to define an unmount callback. This is critical because the editor may restart or the editable component itself may unmount. The code looks something like this:
const unmount = EditorsRegistry.the.mountEffect(editorId, (editor) => {
// Add editable logic
return () => {
// Remove editable logic
};
};
unmount();
In this case, editable is able to unmount itself or respond to the editor’s destruction without leaving garbage on the page, as well as respond to its own reconstruction after the editor restarts.
How do I implement two-way binding?
Two-way binding is a fundamental feature of frameworks that add interactivity to a webpage without turning it into a single-page application (e.g., Phoenix, Blazor, or Livewire). It is therefore expected that the integration will support this. Adding this will be easy, right? Well, actually no! Seemingly trivial, it turns out to be quite complicated due to race conditions, among other things.
Race conditions
Let’s assume we want our component to look like this:
<editor value={@value} onChange={onSetValue} />
In this case, @value is a dynamic value, and onChange is a callback (or event) that sets it. This is where some tricky problems lurk.
Suppose a user types something in the editor, and after a short debounce, onChange is called, which in turn updates @value. So far, everything is fine, but suddenly, the editor loses focus and deletes the user’s last few typed words. Users might ask: “What just happened? Where are my last words?!?” It turns out the problem is that integrations update the component upon receiving a change in @value and overwrite the latest content, which hasn’t been sent to the server yet. My integrations prevent this by detecting whether the editor has focus; if it does, they do not set any values coming from the server. When the user stops typing, the editor waits for the last message to return and then sets the value.
You might ask: why bother reacting to a change in @value at all? In some critical cases, it might be essential to have a button such as a “Clear” button that clears the entire form, including the editor, or a button that loads certain templates.
Strings aren’t everything
As I mentioned earlier, an editor can also consist of multiple roots. Therefore, it is not guaranteed that @value must be a string. You should also keep in mind that you will need to initialize the editor with multiple roots at once.
There are two possible approaches. We can initialize the editor with a map of root values in the main component, or each “editable” can receive its own value separately. You can also use a hybrid approach and initialize it both ways.
<editor value={%{ rootA: "value a" }}>x
<editable name="a" />
<editable name="b" value="value b" />
</editor>
Summary
If you’re tired of the ubiquitous JavaScript environment, don’t want to install tons of NPM packages, and want to stay within your framework’s ecosystem, you absolutely can by integrating CKEditor using one of the above-mentioned integrations.
CKEditor is one of the few WYSIWYG editors that integrates seamlessly with even the most advanced multiple page application frameworks while rivalling the responsiveness of even the best frameworks powering single page applications.
If you would like to get started with CKEditor and don’t already have an account, please start a free trial and then visit our installation guide to get up and running.
Tags:
