Stesura

So you want to have paginated content?

Edoardo Caratti,•7 min read

An insight into the journey of implementing pagination in a rich text editor.

The web isn’t designed for it.

Simple as that, the web is designed to be one continuous flow of text. One could obviously argue that a page could just be a div followed by a page break, but it’s not that simple, especially if you want to be performant… If you’ve perused the rich text editor space long enough, you’ve probably seen a lot of pagination implementations, but most of them either rely on:

Let’s go through them one by one and see where all of them fall short.

Client-side canvas painting

Examples: Google Docs, OnlyOffice. The editor owns every pixel. Google famously ditched the DOM in favor of canvas for Docs for the simple reason that they needed to guarantee pixel-perfection across all browsers and devices, and they’re right — canvas is the only way to guarantee pixel perfection (read more about it here). OnlyOffice took the same route, and it’s the foundation of their pitch for print fidelity and clean docx round-trips.

A quick word on wasm, because it tends to get muddled in this conversation: wasm is not a rendering strategy. It’s a way to run a layout engine — often an existing native one — at near-native speed in the browser. You still have to put the pixels somewhere, and that somewhere is a canvas. So a “wasm editor” is a canvas editor with a native layout core, and it inherits every one of the trade-offs below.

Unless you’re Google, you probably won’t want to go down this path. No, really, don’t. Everything the browser gives you for free, you’ll have to rebuild from scratch;

Server-side rendering streamed to a canvas

Examples: Collabora Online / LibreOffice Online. A different shape of the same trade. A real LibreOffice process opens the document on the server, computes the layout, and streams the result back as image tiles painted onto a canvas. The browser is the remote control; the server is the source of truth. The fidelity is excellent — it’s LibreOffice doing the layout, so of course it is. But extensibility is off the table: you can’t write a plugin against a document your client never actually holds, and the editing loop is only ever as good as the round trip.

Fake pagination solution, using floats to give the impression of pagination

Example: CKEditor 5’s Pagination feature (as of October 2026) There are two flavours here. CKEditor 5’s Pagination feature draws page break lines over one continuous editing surface, showing where the pages will break in the exported PDF or DOCX, which to be honest is not what we’d consider pagination. The other flavour gives you something that looks like pagination, using CSS floats to break the content, but it has its shortcomings. Now, we’re not saying using floats is a bad idea, it’s a fantastic idea, and anyone who took the time to inspect our editor will see we’re doing the exact same thing, but there’s a nuance to it. On its own, using floats is not the problem, but without a pagination engine, the floats have no context, and as such, there are a number of things fake pagination approaches often can’t do:

Node-based solution

This was the original approach taken by Umo editor , but they have since backed away from it. The issue with that is that you are mixing the data model with the visual representation of the editor, and this is not a good idea. There are unfortunately no decorations that allow you to wrap multiple nodes in a single decoration, so you’re stuck needing page nodes in your schema, and boy is that a performance nightmare and creates so much overhead if you need to interact with the content. Picture this: you have a 100-page document, push one line of text, and you’ll potentially have to trigger 200 transactions (one to remove content from one page and one to add it to the next one). ProseMirror is fast, but that’s a lot of overhead for a simple operation. Not to mention all other plugins that rely on inspecting transactions (like track changes, legal numbering, etc) that will have to go through all these transactions as a result. This is a no-go for us.

Fake dom solution

We’ve seen this approach, where the ProseMirror DOM is hidden, and there is a two-way binding between ProseMirror and the rendered DOM. To us, this is the wrong approach — it’s messing with selection and caret positioning, and the two-way binding is not performant at all. In our experience, this approach led to broken selection and fell apart after some odd 20 pages.

A pagination engine to the rescue

To us, there was only one way to go: we needed to handle hundreds of pages without breaking a sweat — widow/orphan control, page breaks that actually break the page, and so much more. So we built a pagination engine and mixed it up with floats, amongst other things. And best of all, the ProseMirror content is utterly unaware of any pagination, everything is done in React. Each node and each line is measured, the browser is performant for these operations, and while you may already be screaming ‘BUT WHAT ABOUT FORCED REFLOWS’, we’ve figured it out too to make it performant. Admittedly, we still have a ways to go, there has been a lot of testing involved and we’re sure edge cases will pop up, but we’re confident we’ve got a solid foundation to build on. Just try it out for yourself and let us know what you think. You may notice some lag on documents that run into the hundreds of pages, but Rome wasn’t built in a day either, and there is still room for us to improve. More aggressive caching, better memoization — the path is laid out for us. Oh, and if you’re wondering about line numbering, well that’s just a by-product of the pagination engine, couldn’t do it without it either! And if we’re perfectly honest, we couldn’t have done it without react-prosemirror either, we keep bringing it back, but by jolly are we ever thankful for the team behind it , and if you want to express your gratefulness, make sure to check out their latest project Pitter Patter .

If you’re still considering implementing your own pagination

I hope this post has given you a better understanding of the challenges involved. Prepare to spend a lot of hours going down that rabbit hole. If not, we’re here for you, consider contacting us to try our editor out.