CKEditor 5 migration from v26 to v47.3: lessons learned from the field

For seven months, I led the migration of the rich-text editor of an editorial application from CKEditor 5 v26 to v47.3, working with a team from the first strategy decisions to go-live. It was one of those upgrades that look like a version bump on paper. In practice it took 66 tickets, 53 sub-PRs and 683 changed files, with about 55 in-house plugins to migrate, replace or delete. It went to production in early September 2026.

This article is an experience report. I am sharing it because when we started, we found plenty of changelogs and very few accounts of what a migration of this size actually looks like. Here is what we learned, from the first decisions to the bugs that only showed up in production, plus the tooling and the open-source work that came out of it.

TL;DR

  • Scope: 24 @ckeditor/* packages replaced by the single ckeditor5 package pinned to 47.3.0, 210 deep imports rewritten, build and React integration rebuilt.
  • Rule: nothing changes for the writers (“iso-v26”), and native CKEditor wherever it exists. A dozen custom plugins disappeared along the way.
  • Main trap: in v47 a configuration error does not break the build. The editor just doesn’t mount, with no visible message.
  • What paid off: tests on the real CKEditor engine, QA feedback grouped by root cause, and AI tooling (an MCP audit server and Claude Code skills) that made each plugin migration repeatable.
  • What slipped through: behaviour differences between a custom plugin and its native replacement, which only real typing revealed.

Key numbers: 24 packages to 1, about 55 plugins, 210 legacy imports, 53 sub-PRs, 683 files, 38 Playwright specs

Why this upgrade was not just a version bump

In this application, CKEditor is not a simple rich-text field: it is the writers’ main tool. It powers the article body, but also the title, the standfirst, captions, credits, live posts and how-to sheets. About forty configuration profiles share some fifty custom plugins: “read also” references, callout boxes, pull quotes, a paywall marker, an enriched byline, a character counter, French typography, drag and drop from a media library, and an export to a print pipeline.

We were still on v26. Four things made the gap hard to close:

  1. The installation method changed in v42. CKEditor is no longer a collection of @ckeditor/ckeditor5-* packages whose sources you import. It is a single ckeditor5 package that re-exports everything. Every import like @ckeditor/ckeditor5-core/src/plugin had to be rewritten: 210 of them, in 348 files.
  2. Some plugins overrode the native ones. Some reused a native pluginName (the image plugin did), others copied native files to modify them. That kind of extension does not survive years of engine changes.
  3. The build was designed for v26. It compiled CKEditor’s CSS sources and swapped icons on the fly in webpack, while v47 ships precompiled CSS and icons.
  4. The project had already stalled once. A first attempt in early 2025 was paused. When it resumed, it was simpler to reapply the changes on a fresh branch than to rebase the package.json and yarn.lock.

In one sentence: every custom plugin was a small migration of its own, with its JavaScript, its server-side configuration, its profiles, its tests and often a React component.

The strategy: boot a bare editor, then climb back plugin by plugin

Get an editor to start before migrating anything

The first step was not to migrate anything but to get an editor that boots. The goal of the very first ticket fit in one line: type and save an article in bold and italic. Everything else was brought back afterwards, one plugin at a time.

Five steps to boot a bare CKEditor 5 v47 editor before migrating any in-house plugin

One epic branch, one ticket per plugin

We opened a long-lived epic branch and created one ticket per plugin. Each ticket branched off the epic and came back through a normally reviewed sub-PR. The team produced 53 sub-PRs, then a single final PR merged everything into the main branch.

This keeps reviews human-sized, since a sub-PR covers a single plugin, and the main branch never sees a state where only half the plugins are migrated. The cost is that a branch living for six months drifts away from the main branch. More on that below.

The contract: “iso-v26”, with native as the target

Two rules guided every decision:

  • Iso-v26. An upgrade must change nothing for the writers. A v47 novelty is documented and proposed, never slipped in.
  • Native first. When CKEditor natively does what a custom plugin did, the custom plugin goes.
Custom plugin (v26) Replaced by (v47)
Focus tracking editor.ui.focusTracker
Placeholder native placeholder option
Horizontal rule HorizontalLine
Mention override (Unicode regex) MentionUI, which now contains the same code
Quote autocorrection TextTransformation
Drag and drop v1 a new drag and drop built on the native clipboard pipeline
References toolbar (~280 lines) WidgetToolbarRepository
Image plugin override native image plugins plus small custom extensions

Lines removed when custom CKEditor plugins were replaced by native features: up to 2,191 lines per plugin

Some of these plugins were already dead code in v26, like a generic link plugin and a social embed that had not worked for a long time. An upgrade is the best moment to inventory what no longer serves. Every deleted plugin is one less plugin to migrate, and above all one less to migrate again next time.

These two rules eventually contradicted each other, though: native is not iso. That is lesson 5.

Nine lessons from the migration

1. In v47, configuration errors make no noise

This family of bugs cost us the most. An editor’s configuration lists its plugins by name, as strings, resolved against the plugins embedded in the bundle through static get pluginName(). Many pluginName values changed during the migration (Hr became HorizontalLine, for example). One constant that didn’t follow, and the editor refuses to start.

The build passes, the page renders, and the field is simply empty. The plugincollection-plugin-not-found error only shows in the browser console, and the Playwright test fails on an “element not visible” timeout that says nothing about the cause. We also hit its variants: a plugin required by another but not declared directly in builtinPlugins, and TextTransformation, which declares Delete and Input by name and needed them listed explicitly even though they were already loaded.

The most spectacular case happened on merge day. A new article edit page, rewritten in parallel by another part of the team, had copied the whole CKEditor configuration catalogue by hand into a template. The migration rewrote the two original sources but not that copy: 17 plugin names no longer existed, and 24 out of 24 editors failed to mount, without a single server-side test going red, because those tests checked that the fields were present, never their configuration. Only the Playwright tests of the new page caught it, and the page was not yet open to users. The fix shipped the same day, along with the missing test: it renders both edit pages and compares their CKEditor configuration, widget by widget.

The five places a CKEditor 5 plugin had to be declared, from builtinPlugins to a copied config catalogue

Takeaway: any copied configuration eventually diverges. If you can’t derive it from a single source, at least add a test that compares the copies. And to diagnose a silent mount failure, a throwaway Playwright spec listening to page.on('console') and page.on('pageerror') finds the cause in one run.

2. Write down everything you comment out to make the editor boot

The bare-editor strategy has a downside. To make v47 start, we commented out dozens of plugins, buttons and options in the JavaScript and server configurations. Each plugin migration was supposed to uncomment them, except when someone forgot. Two clean-up tickets were needed at the end: one to restore every profile to its v26 state field by field, and one to decide on four native plugins set aside at the start (Autoformat, Autosave, BlockQuote, EasyImage). After checking the code, only Autoformat brought a real feature: Autosave had no save function and EasyImage no backend. The historical functional tests had also been switched off in CI and needed their own ticket to come back.

Takeaway: anything you disable “temporarily” must be written down at that moment, with the reason. Otherwise the end of the project becomes archaeology in git log.

3. CSS and build: dead code that doesn’t say it’s dead

Moving to CKEditor’s precompiled CSS had an effect nobody anticipated. Our custom theme redirected the @import statements of CKEditor’s CSS sources to its own files. Those imports no longer exist, so about forty theme files silently stopped applying. Block widgets lost their top margin, and the build-time icon replacement found nothing left to replace, so the toolbar showed native icons instead of ours.

On the build side, we moved from Webpack Encore to a dedicated native webpack configuration. Two consequences. Implicit artefacts disappeared (the manifest, runtime.js, the translations): everything worked while old files lingered in the output folder, and BalloonEditor is not defined appeared after an rm -rf. And the files were no longer versioned: ckeditor.js kept the same name across deployments and browsers kept it in cache. During QA, several reports were “fixed by clearing the cache”. Adding [contenthash] to the output names solved it, without touching the templates.

Takeaway: after a build change, validate on an empty output folder, check that files are versioned, and in front of any visual regression, first ask whether the CSS rule lives in a file that is no longer loaded.

4. Test against the real CKEditor

Our Jest unit tests replaced the ckeditor5 module with a mock. They checked our calls, not the editor’s behaviour. One real example: a callout plugin test mocked ModelText with an empty class, so every instanceof was false and the “selected text” branch stayed green without ever running.

We built an integration harness that runs the real v47 engine inside Jest. It took a few adjustments: importing the sub-packages’ dist/ rather than the umbrella package, jsdom polyfills, and licenseKey: 'GPL'. It revealed behaviours invisible with mocks and protected us from false regressions: before fixing a behaviour, we check whether it is native and whether it already existed. Its limits are structural: jsdom never fires selectionchange and computes no layout, so selection, keyboard navigation, scrolling and drag and drop belong in a real browser. That is where our 38 new Playwright specs came in.

Takeaway: a test is only worth something if you have seen it red without the fix, with a rebuilt bundle. The habit that paid off most: mutate the production code (remove the fixed line) and check that the expected test is the one that fails.

5. Native is not iso: replacing a plugin changes its contract

The best example fits in one character: the French quotation mark. Our custom plugin intercepted the " key and directly inserted « or ». We replaced it with the native TextTransformation, which removed custom code and followed our rule. But the native plugin works differently:

  1. it substitutes after the fact: it lets you type ", then replaces it;
  2. it turns the next Backspace into an undo: on «bonjour», Backspace gave back "bonjour" instead of deleting the »;
  3. it only sees the text after the last inline element: a reference or a non-breaking space inside a quote hides the opening « from it.

None of this showed up in QA. The first reports came from writers, after go-live. The fix keeps the native plugin and adds a small French typography plugin that re-reads the whole block and corrects after the fact. We kept three deliberate differences, each locked by a named characterisation test so nobody rediscovers it later as a regression.

The same reasoning applied elsewhere. Lists: in v47, an image pasted into a bullet became a list item; one line of configuration, list: { multiBlock: false }, restored the v26 list model. Drag and drop: a media dragged from outside the editor showed an inline caret and split the paragraph, because the native block mode is only computed for drags that start inside the editor. That one went upstream (see below).

Takeaway: when a native plugin replaces a custom one, the question is not “does it do the same thing?” but “does it behave the same under the fingers?”. Compare typing, Backspace and Ctrl+Z. And the team rule that came out of it: we don’t “fix” a native behaviour because it is imperfect. We only depart from it at the product’s request, because every deviation is a debt to re-validate at each upgrade.

6. Rebase a six-month epic branch with method

At the first big rebase, the main branch was 277 commits ahead, and the epic branch carried 22 merge commits that git rebase --rebase-merges tried to rebuild one by one. What we learned:

  • Rebase linearly. Sub-PR merges carry no content; dropping them removes as many stopping points.
  • Sort the conflicting files. Generated files (yarn.lock, test databases, search index dumps) are regenerated, never merged by hand. The real conflicts were in about ten CKEditor configuration files.
  • Set up a safety net. A tag and a backup branch before each rebase, with rerere enabled.
  • Review everything afterwards, including “generated” files: an automatic resolution had silently re-enabled tests from the main branch’s Makefile.

The procedure ended up as a dedicated Claude Code skill, deleted after the merge. Just before the final PR we cleaned up the history: 86 commits became 68, with the final tree identical to the hash, proof that no line of code had moved. The last rebase replayed the 68 commits without a single conflict.

Takeaway: rebasing often costs less than rebasing well. And a clean history is not vanity: on a branch this size, it is what makes git bisect possible the day production breaks.

7. QA: group by cause, not by symptom

Functional QA produced 20 points. Instead of one ticket per point, or groups by apparent similarity, we used one criterion: does a single fix make all these points go away? It had to be checked in the code each time. Seven placeholder points ended up in a single ticket because they had one cause: the v47 native placeholder and our blue editing indicator were fighting over the same ::before pseudo-element. Two similar-looking points turned out to have nothing to do with CKEditor, and one of them already existed in production in v26. Under an iso-v26 rule, removing it would have been a deviation, not a fix, so it was handled as a feature request.

Takeaway: before hunting a bug in a QA report, check two things: does the served bundle contain the fix? and does this behaviour already exist in production?

8. Product decisions are not made in the code

Several “bugs” were deliberate CKEditor behaviour changes that required a product decision. In v47, the block menu button (+) became a drag handle: we presented three options with their costs, and after watching real usage the team chose two separate buttons, a + that opens the menu and a handle that moves the block. The “dash + space creates a bullet list” shortcut had not been forgotten either: it was suspended until someone decided whether it was useful, and we presented its reactivation as a decision taken, not as an oversight fixed.

Takeaway: document the options, their cost and the current behaviour, then let the product decide. The developer’s job is to make the decision possible, not to make it for them.

9. AI as a migration tool: industrialise the knowledge, not just the code

A large part of the migration was done with AI coding agents, mostly Claude Code. That deserves its own section.

The tooling: an MCP server, Claude Code skills and a seven-step routine

ckeditor_audit, the MCP server that kept score

With some fifty plugins moving at different speeds, the hard part was not technical but bookkeeping: knowing at any moment what was done, half-done or not started. Asking an AI to re-read hundreds of files every session was slow and expensive, so I wrote ckeditor_audit, a read-only MCP (Model Context Protocol) server that Claude Code queries directly.

  • For each plugin it returns a status (migrated, partial, not_migrated, no_imports), every remaining legacy import with file and line, its suggested modern replacement, and the configuration files that reference the plugin, separating active references from commented-out ones.
  • Its mid-project report counted 55 plugins, 66% migrated, and gave the team a shared view of progress.
  • One audit call returns about 200 tokens where naive file reading costs 6,000 to 15,000: 80 to 95% fewer discovery tokens per session.

I told the full story of this server in I turned a CKEditor 5 v26→v47 migration into an MCP server. The code is public, and the legacy → modern mapping lives in a JSON file, so it can serve other CKEditor migrations too.

A migration skill that learns from every plugin

  • A plugin migration skill, enriched after each migration with the traps we met: imports, schema, placeholders, focus, the five activation points, tests. After a review we split it following the progressive disclosure principle: a short main file that routes, and six reference files loaded on demand.
  • A seven-step routine: audit, branch, migration, simplification, lint and tests, Playwright, atomic PR. It came with explicit criteria for calling a plugin “migrated” and S/M/L sizing, so any developer could migrate a plugin without depending on an expert.
  • Plans and reports written before coding, which the team could read and challenge line by line.

What had to be kept in check

  • Over-confident diagnoses. Several hypotheses stated with aplomb turned out wrong, such as “the React 6 wrapper is incompatible with v47”; one green test on the article body was enough to rule it out. Our rule became: measure before asserting, by reading node_modules, checking in the browser, or watching a test go from red to green.
  • False positives in the tooling itself. The text-based audit counted imports found in JSDoc comments and flagged a renamed plugin as “not migrated”. A measuring tool has to be verified too.
  • The temptation of volume. An agent happily writes seven end-to-end tests where one makes sense, the other six duplicating a more precise integration test.

Takeaway: AI mostly speeds up capitalisation: a trap met once becomes a line in the skill and costs nothing the next time. It does not exempt you from proving what you claim. It makes proof even more necessary, because it produces hypotheses faster than you can check them.

Giving back upstream: two CKEditor issues

A migration like this also shows you where the framework itself could help. Two topics went back to the CKEditor project, from my personal account and in a neutral form.

  • Making spaces visible: issue #1700. Our writers needed to see the simple spaces they typed. The only reference was a proof of concept by @scofalik in an issue open since 2019. I joined the discussion, then packaged a v47-compatible, dependency-free plugin with a toolbar toggle that never touches saved data: ckeditor5-simple-space. The story is in Making simple spaces visible in CKEditor 5.
  • External drops that split paragraphs: issue #20247. The drag-and-drop issue from lesson 5. The root cause is a private _blockMode flag only computed when a drag starts inside the editor. I opened the issue in September 2026 with a one-click StackBlitz reproduction, a short video and a two-part API proposal. A maintainer acknowledged it the next day and it is labelled for the core team. Root cause, workaround and demo are in CKEditor 5 drag and drop: external block drops split paragraphs.

In both cases the rule was the same: remove every line of your own code from the reproduction, so nobody can blame the integration.

After go-live

The merge into the main branch happened at the end of July 2026, followed by five weeks of QA on the release branch and production in early September. The issues that came next fell into two families.

Those only real typing reveals: the French quotation marks from lesson 5, a » that would no longer close, a Backspace that undid the whole pair, then a quote that didn’t close after a non-breaking space.

Those born from v47 meeting our server-side code:

  • Two consecutive tables merged on load, and the paragraph between them vanished on save. A greedy server-side regex wrapped both tables and the paragraph in one <figure>, which v47 read as a single table. The regex had been copied, untested, into a second code path.
  • The editor’s post-fixer looped on the revision history page when the article contained images.
  • Read-only mode arrived too late. The React wrapper only applies read-only after the first render, which is exactly when our widgets decide whether to show their action buttons. A “delete” button stayed visible in a supposedly read-only preview.

All were fixed within a month, each with a regression test. Takeaway: book dedicated time after go-live with someone who knows the migration. These reports always come, and first from the most demanding users.

Results, what’s left, and v49

What v47 brings: a maintainable base with no deep import left in production code and a dozen custom plugins replaced by native ones; visible gains for writers (a fully French UI, undo and redo, more reliable text drag and drop, richer automatic typography, widgets that can no longer break a list); access to the v47 ecosystem (find and replace, full screen, paste from office, richer tables and lists); and much better test coverage, with 38 Playwright specs, an integration harness on the real engine and a parity test between the two edit pages.

What’s left: duplicated configuration that is now protected by a test but not yet derived from the source; remaining custom icons that v47 replaces with native ones, waiting for a design decision; and the React wrapper, to upgrade from 6 to 12 together with v49.

The next upgrade, to v49, is already analysed and directly benefits from this work: there is no deep import left to rewrite. It has its own risks: without a licenseKey, the React 12 wrapper silently overrides our extraPlugins, and there is a new default theme and the removal of the Watchdog. Our rule there is the one this migration taught us: keep the current behaviour by default. A novelty is adopted only if it is safe and cheap; otherwise it goes into a follow-up ticket.

If I had to do it again

Eight-point checklist for a CKEditor 5 major-version migration, from plugin inventory to post go-live time

  1. Inventory before migrating: for each plugin, its fate (migrate, replace with native, delete), its activation points and its test coverage.
  2. Start as small as possible, writing down everything you disable to get there.
  3. Rebase often, linearly, following a written procedure.
  4. Set up the real-engine test harness from the first plugin, not halfway through.
  5. Test the configuration, not just the presence of fields, and compare every copy of the configuration.
  6. Compare custom and native plugins keystroke by keystroke before deleting the custom one.
  7. Version the assets from the first QA deployment.
  8. Group QA feedback by cause, verified in the code.
  9. Write the knowledge into the tooling (skills, checklists, an audit server), not only into people’s heads.
  10. Book time after go-live.

Frequently asked questions

What is the biggest change when upgrading CKEditor 5 from v26 to v47?

The installation method introduced in v42. Instead of many @ckeditor/ckeditor5-* packages imported from their src/ folders, you use the single ckeditor5 package and named imports such as import { Plugin } from 'ckeditor5'. Every deep import must be rewritten, and a single leftover one can load a second copy of the library and make every editor on the page disappear.

Why does my CKEditor 5 field stay empty after the upgrade?

Most often because a plugin name in the configuration no longer matches any plugin in the bundle. The plugincollection-plugin-not-found error only appears in the browser console. Check the static pluginName of the class, not the name you import it under, and look at every place where the plugin list is declared.

Is licenseKey: 'GPL' required in CKEditor 5 v47?

Yes. Without it, ClassicEditor.create() and BalloonEditor.create() refuse to start, including in Jest integration tests. Note that the GPL key is rejected by the LTS line and by the CDN, which expect a commercial key.

Should I replace my custom CKEditor plugins with native ones?

Whenever the native feature covers the need, yes: it is less code to maintain and to migrate next time. But compare the behaviour under the fingers first (typing, Backspace, undo), because a native plugin can implement the same feature with a different contract.

Can AI agents really help with a CKEditor migration?

Yes, mostly by making knowledge reusable: a migration skill that grows after every plugin, and an MCP audit server such as ckeditor_audit that keeps track of progress for a fraction of the tokens. They do not replace verification: every diagnosis still has to be proven by a test or a measurement.

Further reading

If you are preparing a similar upgrade and want to compare notes, the code of the tools above is public and feedback is welcome.

Leave a Reply

Your email address will not be published. Required fields are marked *