Next.js Rich Text Editor
Scribe Editor is the lightweight, zero-dependency WYSIWYG editor for Next.js. Because Scribe is DOM-only, a single dynamic() import with { ssr: false } is the entire integration — App Router and Pages Router both work.
Why use Scribe in your Next.js app?
Under 50KB gzipped
Zero runtime dependencies. No ProseMirror, no Quill core. Loaded client-side only, so it never bloats your server bundle.
One dynamic import
dynamic(() => import(...), { ssr: false }) is the whole integration. No custom webpack config, no hydration hacks.
App Router native
Mark the editor 'use client', load it lazily, and it composes cleanly inside server components and Server Actions.
Built-in XSS sanitization
Safe paste handling from Word and Google Docs out of the box. Still sanitize on the server before persisting user HTML.
The client editor component
Scribe touches the DOM, so the editor must be a client component. Mark it 'use client', initialize in useEffect, and clean up with destroy() on unmount.
// components/RichTextEditor.tsx
'use client';
import { useRef, useEffect } from 'react';
import { Scribe } from 'scribejs-editor';
import 'scribejs-editor/dist/scribe.css';
export default function RichTextEditor({
defaultValue = '',
onChange,
}: {
defaultValue?: string;
onChange?: (html: string) => void;
}) {
const editorRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<ReturnType<typeof Scribe.init> | null>(null);
useEffect(() => {
if (!editorRef.current) return;
instanceRef.current = Scribe.init(editorRef.current, {
toolbar: 'floating',
placeholder: 'Start writing...',
onChange: (html) => onChange?.(html),
});
return () => instanceRef.current?.destroy();
}, []);
return (
<div ref={editorRef} dangerouslySetInnerHTML={{ __html: defaultValue }} />
);
}Load it with dynamic() and ssr: false
From a server component, import the editor through next/dynamic with { ssr: false }. This skips server rendering and stops the window is not defined error.
// app/editor/page.tsx — App Router server component
import dynamic from 'next/dynamic';
// ssr: false skips server render so 'window is not defined' never fires.
const RichTextEditor = dynamic(
() => import('@/components/RichTextEditor'),
{ ssr: false }
);
export default function EditorPage() {
return (
<main>
<h1>Write a post</h1>
<RichTextEditor onChange={(html) => console.log(html)} />
</main>
);
}Submitting content with a Server Action
Track the HTML in client state from Scribe's onChange, mirror it into a hidden input, then submit to your Server Action. Re-sanitize on the server before persisting.
// app/posts/new/page.tsx
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
const RichTextEditor = dynamic(
() => import('@/components/RichTextEditor'),
{ ssr: false }
);
export default function NewPost() {
const [html, setHtml] = useState('');
return (
<form action={async () => { /* call a Server Action with html */ }}>
<RichTextEditor onChange={setHtml} />
<input type="hidden" name="body" value={html} />
<button type="submit">Publish</button>
</form>
);
}Everything you need, nothing you don't
Add a rich text editor to Next.js in minutes.
No license keys. No CDN links. No account. Just npm install scribejs-editor and one dynamic import.
Next.js + Scribe — common questions
How do I add a rich text editor to a Next.js app?
Create a client component marked with 'use client' that calls Scribe.init() in a useEffect, then load it with next/dynamic using { ssr: false }. This keeps Scribe out of server rendering and hydrates it correctly on the client. Clean up with editor.destroy() on unmount.
Why do I get "window is not defined" with a rich text editor?
Scribe needs the browser DOM, but Next.js tries to render components on the server where window does not exist. Wrapping the editor in dynamic(() => import(...), { ssr: false }) skips server rendering for that component so the browser-only code never runs during SSR.
Does Scribe work with the Next.js App Router and React Server Components?
Yes. Keep the editor itself in a 'use client' component and import it from a server component via next/dynamic with ssr: false. Server components stay on the server; the editor hydrates only on the client. It also works in the older Pages Router with the same dynamic import.
How do I submit Scribe content with a Server Action?
Track the HTML in client state from Scribe’s onChange, mirror it into a hidden input (or pass it directly), then submit the form to your Server Action. Always re-sanitize the HTML on the server before persisting it, even though Scribe sanitizes paste input on the client.
Also compare Scribe with:
Use Scribe in your framework: