Oliver Wolfson
ServicesProjectsContact

Development Services

SaaS apps · AI systems · MVP builds · Technical consulting

Services·Blog
© 2026 O. Wolf. All rights reserved.
Web Development
Building a Monaco Editor Terminal with Vim Mode in Next.js 15 (App Router)
This tutorial shows how to build a powerful in-browser code/markdown terminal using the Monaco Editor, with Vim keybindings, terminal-like command input, and serverless-ready SSR-safe deployment—using Next.js 15’s App Router.
July 12, 2025•O. Wolfson

Live demo: monaco-vim-green.vercel.app

Source: github.com/owolfdev/monaco-vim


Overview

This guide shows how to build a powerful in-browser Markdown/code terminal using the Monaco Editor (the engine behind VS Code) with Vim keybindings, terminal-style commands, and seamless SSR-safe deployment—using Next.js 15’s App Router.

  • VS Code experience in the browser
  • Vim mode toggle: For power users
  • Command interface: Enter commands like help, vim, or clear
  • Ready for deployment on Vercel

Project Structure

src
├── app
│   ├── favicon.ico
│   ├── globals.css
│   ├── layout.tsx
│   └── page.tsx            // Loads <MdxEditor />
├── components
│   └── mdx-editor.tsx      // The main Monaco Terminal Editor
└── types
    └── monaco-vim.d.ts     // Minimal types for monaco-vim

1. Install Dependencies

npm install next@latest react react-dom @monaco-editor/react monaco-editor monaco-vim

2. Add Monaco Editor via Dynamic Import

Monaco (and monaco-vim) only work in the browser, so use Next.js’s dynamic() import with ssr: false:

// in mdx-editor.tsx
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
  ssr: false,
});

3. Add monaco-vim Type Declarations

Create src/types/monaco-vim.d.ts:

declare module "monaco-vim" {
  import * as monaco from "monaco-editor";
  export function initVimMode(
    editor: monaco.editor.IStandaloneCodeEditor,
    statusBar: HTMLElement
  ): { dispose(): void };
  export type VimMode = ReturnType<typeof initVimMode>;
}

4. Create the Editor Component

src/components/mdx-editor.tsx

"use client";

const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
  ssr: false,
});

const COMMANDS: Record<string, string> = {
  help: `Available commands:
help     Show help info
clear    Clear the editor
vim      Enable Vim mode
novim    Disable Vim mode
explain  Explain the code
fix      Suggest a fix
doc      Generate documentation
`,
  explain: "AI: Here's an explanation of your code (placeholder).",
  fix: "AI: Here's a suggested fix (placeholder).",
  doc: "AI: Here's generated documentation (placeholder).",
  vim: "Vim mode enabled!",
  novim: "Vim mode disabled!",
  "no vim": "Vim mode disabled!",
};

type MdxEditorProps = {
  initialCode?: string;
  height?: string | number;
};

export default function MdxEditor({
  initialCode = `# MDX Editor Terminal

Type 'help', 'vim', or 'novim', then press Enter!
Or use the button below to toggle Vim mode.
`,
  height = "400px",
}: MdxEditorProps) {
  const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
  const vimStatusBarRef = useRef<HTMLDivElement | null>(null);
  const vimModeRef = useRef<VimMode | null>(null);
  const [vimEnabled, setVimEnabled] = useState(false);
  const [monacoVim, setMonacoVim] = useState<{
    initVimMode: typeof import("monaco-vim").initVimMode;
  } | null>(null);

  // Lazy load monaco-vim
  useEffect(() => {
    if (typeof window !== "undefined" && !monacoVim) {
      import("monaco-vim").then((vim) => setMonacoVim(vim));
    }
  }, [monacoVim]);

  // Cleanup Vim
  const cleanupVim = useCallback(() => {
    if (vimModeRef.current) {
      vimModeRef.current.dispose();
      vimModeRef.current = null;
    }
    if (vimStatusBarRef.current) {
      vimStatusBarRef.current.innerHTML = "";
    }
  }, []);

  const enableVim = useCallback(async () => {
    cleanupVim();
    if (editorRef.current && vimStatusBarRef.current) {
      let vim = monacoVim;
      if (!vim) {
        vim = await import("monaco-vim");
        setMonacoVim(vim);
      }
      vimModeRef.current = vim.initVimMode(
        editorRef.current,
        vimStatusBarRef.current
      );
      setVimEnabled(true);
    }
  }, [cleanupVim, monacoVim]);

  const disableVim = useCallback(() => {
    cleanupVim();
    setVimEnabled(false);
  }, [cleanupVim]);

  const handleEditorDidMount = (
    editor: monaco.editor.IStandaloneCodeEditor,
    monacoInstance: typeof monaco
  ) => {
    editorRef.current = editor;

    setTimeout(() => {
      editor.focus();

      const model = editor.getModel();
      if (model) {
        let lastLine = model.getLineCount();
        if (model.getLineContent(lastLine).trim() !== "") {
          model.pushEditOperations(
            [],
            [
              {
                range: new monacoInstance.Range(
                  lastLine + 1,
                  1,
                  lastLine + 1,
                  1
                ),
                text: "\n",
                forceMoveMarkers: true,
              },
            ],
            () => null
          );
          lastLine += 1;
        }
        editor.setPosition({
          lineNumber: lastLine,
          column: 1,
        });
      }
    }, 0);

    editor.addCommand(
      monacoInstance.KeyCode.Enter,
      async () => {
        if (!editorRef.current) return;
        const position = editorRef.current.getPosition();
        if (!position) return;
        const model = editorRef.current.getModel();
        if (!model) return;

        const lineContent = model
          .getLineContent(position.lineNumber)
          .trim()
          .toLowerCase();

        if (lineContent === "clear") {
          editorRef.current.setValue("");
          return;
        }
        if (lineContent === "vim") {
          await enableVim();
        }
        if (lineContent === "novim" || lineContent === "no vim") {
          disableVim();
        }
        const output = COMMANDS[lineContent];
        if (output) {
          const edits = [
            {
              range: new monacoInstance.Range(
                position.lineNumber + 1,
                1,
                position.lineNumber + 1,
                1
              ),
              text: output + "\n",
              forceMoveMarkers: true,
            },
          ];
          model.pushEditOperations([], edits, () => null);

          const linesInserted = output.split("\n").length;
          editorRef.current.setPosition({
            lineNumber: position.lineNumber + linesInserted + 1,
            column: 1,
          });
        } else {
          editorRef.current.trigger("keyboard", "type", { text: "\n" });
        }
      },
      ""
    );
  };

  useEffect(() => {
    return () => {
      cleanupVim();
    };
  }, [cleanupVim]);

  return (
    <div>
      <div style={{ height, marginBottom: 8 }}>
        <MonacoEditor
          height="100%"
          defaultLanguage="markdown"
          defaultValue={initialCode}
          theme="vs-dark"
          onMount={handleEditorDidMount}
          options={{
            minimap: { enabled: false },
            wordWrap: "on",
            fontSize: 16,
            fontFamily: "monospace",
            renderLineHighlight: "none",
          }}
        />
      </div>
      <div
        ref={vimStatusBarRef}
        style={{
          height: 24,
          background: "#222",
          color: "#fff",
          padding: "2px 10px",
          fontFamily: "monospace",
          fontSize: "14px",
          marginBottom: 12,
          display: vimEnabled ? "block" : "none",
        }}
      />
      <button
        className={`px-4 py-2 rounded font-semibold mt-2 ${
          vimEnabled ? "bg-green-700 text-white" : "bg-gray-700 text-gray-200"
        }`}
        style={{
          border: 0,
          outline: 0,
          cursor: "pointer",
        }}
        onClick={vimEnabled ? disableVim : enableVim}
      >
        {vimEnabled ? "Disable Vim Mode" : "Enable Vim Mode"}
      </button>
      <div className="text-sm mt-2" style={{ opacity: 0.7 }}>
        Vim mode is <b>{vimEnabled ? "ON" : "OFF"}</b>
      </div>
    </div>
  );
}

5. Use Your Editor in Any Page

src/app/page.tsx

export default function HomePage() {
  return (
    <main
      className="bg-neutral-900 text-white h-screen"
      style={{ padding: 24 }}
    >
      <h2 className="text-2xl font-bold text-white mb-4">
        MDX Editor (Monaco + Vim Toggle)
      </h2>
      <MdxEditor />
    </main>
  );
}

6. Why This Works with Vercel and Next.js 15

  • All browser-only code is isolated to the client.
  • No SSR build errors (window is not defined) thanks to dynamic() and use client.
  • Easily expandable to new commands or integrations.

7. Next Steps

  • Add AI/chat integration for live markdown/code feedback.
  • Style to match your site (custom themes, Tailwind, etc).
  • Support command history, live Markdown preview, or even real MDX output blocks.

Resources

  • Live demo
  • GitHub repo
  • Monaco Editor docs
  • monaco-vim
  • Next.js dynamic imports
Tags
#vim#vscode#motions#vim motions series#text editor#text#editor#ide#input#textarea#monaco#text area