Preloader
Others
  • Estimated reading time: 8 Minutes

Adding a Rich Text Editor to Next.js: What SSR Taught Us

Adding a Rich Text Editor to Next.js: What SSR Taught Us

Adding a rich text editor to a React application usually feels straightforward. Install the package, import the component, give it some initial content, and start handling changes.

That was the assumption when we looked at adding a Next.js rich text editor for content such as blog posts, product descriptions, knowledge-base articles, and CMS entries. Next.js is built on React, after all. How different could the integration be?

The answer became clear as soon as server rendering entered the picture.

Errors such as window is not defined or document is not defined are obvious signs of the problem, but they are only symptoms. Hydration differences and editor initialization issues can point to the same underlying architectural mismatch.

What finally made the integration simpler was not trying to make the editor participate in server rendering. Instead, we separated what Next.js should handle on the server from what a rich text editor genuinely needs to do in the browser.

That distinction turned out to be the most important lesson.

Why a Rich Text Editor Changes the SSR Equation

The App Router in Next.js uses Server Components by default. Interactive parts of an application can then be moved into Client Components with the "use client" directive.

That architecture works well because plenty of application code does not need to run in a browser.

A rich text editor is different.

Editors typically depend on browser capabilities such as:

  • window and document
  • DOM nodes
  • selections and ranges
  • keyboard and mouse events
  • focus management
  • clipboard behavior
  • image and file interactions

They may also maintain toolbars, popups, floating controls, cursor positions, and other state that only makes sense after a document exists in the browser.

So importing a rich text editor is not quite the same as importing a heading, card, or another mostly presentational React component.

The important realization is this:

The page can benefit from server rendering without the editor itself needing to be server-rendered.

Once we started thinking about those two concerns separately, the architecture became much easier to reason about.

Lesson 1: "use client" Is Necessary, but It Is Not the Whole Architecture

The first step is putting the interactive editor behind a Client Component boundary.

A simplified wrapper might start like this:

'use client';

export default function Editor() {
  // Editor implementation
  return <div>{/* editor renders here */}</div>;
}

The "use client" directive tells Next.js that this component requires client-side capabilities such as state, event handlers, and browser APIs.

But there is an easy mistake to make here: turning the entire page into a Client Component simply because one part of it contains an editor.

Suppose we are building an article editing screen. Conceptually, the component tree might look like this:

PostEditPage        → Server Component
 ├─ PostMetadata    → Server-rendered
 └─ RichTextEditor  → Client Component

The page can still fetch article data and render noninteractive UI on the server. Only the part that requires browser interactivity crosses the client boundary.

That separation gives us a much cleaner architecture.

Server-side data fetching can remain on the server. Static or noninteractive parts of the page do not need unnecessary client-side JavaScript. Meanwhile, the editor gets exactly the environment it requires.

The lesson was not simply “put "use client" at the top of the file.”

It was to make the client boundary as deliberate as possible.

Lesson 2: Sometimes the Editor Itself Should Be Loaded Dynamically

A Client Component boundary solves one part of the problem, but some editor packages can introduce another issue.

A package or one of its dependencies may reference browser globals while its module is being evaluated. If that code reaches the server environment, the application can still encounter errors even though the editor is conceptually client-side.

This is where next/dynamic can help.

For a browser-dependent editor, we can load the component like this:

import dynamic from 'next/dynamic';

const RichTextEditor = dynamic(
  () => import('./RichTextEditor'),
  {
    ssr: false,
    loading: () => <p>Loading editor...</p>,
  }
);

Using { ssr: false } means that the component is not rendered during server-side rendering. Next.js supports this pattern for dynamically imported components that should execute only in the browser.

That gives the editor a stronger browser-only boundary while allowing the surrounding page to keep its server-rendering behavior.

The important word here is when.

We would not add ssr: false to every interactive component. A normal button, form field, or React stateful component does not automatically need SSR disabled.

Dynamic loading becomes useful when the library genuinely assumes a browser environment or when delaying its initialization provides a cleaner integration.

Treat it as an architectural tool, not a standard checkbox for every Client Component.

Lesson 3: Keep Server Data and Editor State Separate

Once the editor renders correctly, the next question is where the content should live.

Consider an edit page for an existing article.

The flow can stay surprisingly simple:

Database / API
      ↓
Server Component
      ↓ initial HTML
Client Rich Text Editor
      ↓ updated HTML
API / Server Action
      ↓
Database

The server fetches the stored article. It can also determine whether the current user has permission to edit it and render the surrounding page.

The initial HTML is then passed into the editor.

From that point, editing becomes a browser-side concern. The editor manages the current document, formatting operations, cursor position, selections, and temporary UI state. When the user saves, the updated content goes back to the application through an API endpoint or Server Action.

A simplified client wrapper might look something like this:

'use client';

import { useState } from 'react';

export default function EditorWrapper({
  initialContent,
}: {
  initialContent: string;
}) {
  const [content, setContent] = useState(initialContent);

  async function save() {
    await fetch('/api/posts/current', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content }),
    });
  }

  return (
    <>
      <RichTextEditor
        value={content}
        onChange={setContent}
      />

      <button onClick={save}>Save</button>
    </>
  );
}

The exact editor API will vary, but the responsibility split remains useful.

The server handles fetching, authorization, validation, sanitization, and persistence.

The editor handles interactive editing, formatting, selection, focus, and temporary UI state.

SSR does not mean every part of an editing workflow has to execute on the server.

Lesson 4: Watch for Hydration and Initial-Content Differences

Not every SSR-related editor problem produces a window is not defined error.

Some appear as hydration warnings instead.

Hydration depends on the browser receiving markup that can be reconciled with what React expects when client-side execution begins. Problems can occur when the initial server output and immediate client output differ.

Rich text editors make this easier to encounter because they often take ownership of their DOM.

Differences may come from:

  • transforming stored HTML only in the browser;
  • generating IDs or other runtime values during rendering;
  • replacing initial markup with editor-generated DOM;
  • rendering placeholders differently on the server and client.

Our preferred solution is not to make the server reproduce the editor's internal markup.

Instead, we establish a predictable point where the editor container becomes client-owned.

A dynamically loaded editor also makes that intention explicit:

const RichTextEditor = dynamic(
  () => import('./RichTextEditor'),
  {
    ssr: false,
    loading: () => <p>Loading editor...</p>,
  }
);

The server can render the surrounding layout and loading state. Once the browser is ready, the editor initializes inside its own boundary.

That is usually easier to reason about than trying to make complex editor-generated DOM match across two different execution environments.

What Our Final Next.js Rich Text Editor Architecture Looked Like

Bringing these lessons together gave us a simple model for the integration:

Next.js Page
│
├── Server Component
│   ├── Fetch article
│   ├── Check permissions
│   └── Render page structure
│
└── Client Editor Wrapper
    │
    └── Dynamically loaded rich text editor
        ├── Initial HTML
        ├── Formatting
        ├── Editing state
        └── onChange / save

The advantage of this structure is that neither side is being asked to do the other's job.

Next.js can continue using its server-side capabilities for data access and page composition. The editor can operate in the browser environment it expects.

The pattern also works with existing React editor integrations.

For example, a React rich text editor such as Froala can sit inside this client-side boundary while the rest of the Next.js page remains server-oriented. Froala provides its React integration through the react-froala-wysiwyg package, with model and change-handling APIs for connecting editor content to React state.

The specific editor can change. The architectural boundary does not have to.

That was more useful to us than building the entire integration around the behavior of one library.

The Rules We Would Follow Next Time

If we were adding another rich text editor to Next.js, we would start with a few architectural rules rather than waiting for SSR errors to tell us where the boundaries should be.

First, keep browser-dependent editors inside a clearly defined Client Component.

Second, do not move an entire page to client rendering just because one part needs interactivity. Keep server-rendered functionality on the server where it still provides value.

Third, dynamically import the editor when the library genuinely depends on browser-only behavior during loading or initialization.

Fourth, separate persisted content from editor state. Let the server handle fetching, authorization, validation, and persistence while the editor focuses on the editing experience.

Finally, treat HTML generated by a WYSIWYG editor as untrusted input when it returns to the server.

An editor can improve the content-authoring experience and may provide its own safeguards, but it should not become the application's security boundary. Server-side validation and sanitization should still be part of the content pipeline.

SSR Wasn't the Problem; the Boundary Was

Our main takeaway is not that rich text editors and SSR are incompatible.

It is that rich text editors and SSR have different responsibilities.

A well-structured Next.js application can preserve server rendering where it provides value while giving browser-dependent functionality a deliberate client-side environment.

That means the page can still fetch data, check permissions, and render its structure on the server. The editor can then initialize in the browser, manage interactive state, and return updated content when the user saves.

Once we stopped thinking of the editor as something that needed to participate in server rendering, the integration became much easier to understand.

That is now the first architectural decision we would make when adding a Next.js rich text editor: decide exactly where the server's responsibility ends and the editor's begins.

Related articles
Weekly trending
Our Sponsors

Our blog is proudly supported by industry-leading sponsors.