Next.js

    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.

    $npm install scribejs-editor
    < 50KB
    Gzipped bundle
    0
    Runtime dependencies
    1 import
    To integrate

    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

    Bold, italic, underline, strikethrough
    Headings H1–H6
    Ordered & unordered lists
    Links with inline editing
    Floating toolbar on selection
    Fixed toolbar mode
    Paste safe from Word & Google Docs
    Built-in XSS sanitization
    App Router & Pages Router support
    Plugin system for extensibility
    Full TypeScript types included
    MIT license — free forever

    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.