OWolf

AboutBlogProjects
©2025 OWolf.com

Privacy

Contact

Web Development

Building a Monaco Editor Terminal with Vim Mode in Next.js 15 (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

bash
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:

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

3. Add monaco-vim Type Declarations

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

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

tsx
"use client";

import React, { useRef, useEffect, useState, useCallback } from "react";
import dynamic from "next/dynamic";
import type * as monaco from "monaco-editor";
import type { VimMode } from "monaco-vim";

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 () {
   editorRef = useRef<monaco.. | >();
   vimStatusBarRef = useRef< | >();
   vimModeRef = useRef< | >();
   [vimEnabled, setVimEnabled] = ();
   [monacoVim, setMonacoVim] = useState<{
    :  ().;
  } | >();

  
  ( {
     (  !==  && !monacoVim) {
      ().( (vim));
    }
  }, [monacoVim]);

  
   cleanupVim = ( {
     (vimModeRef.) {
      vimModeRef..();
      vimModeRef. = ;
    }
     (vimStatusBarRef.) {
      vimStatusBarRef.. = ;
    }
  }, []);

   enableVim = ( () => {
    ();
     (editorRef. && vimStatusBarRef.) {
       vim = monacoVim;
       (!vim) {
        vim =  ();
        (vim);
      }
      vimModeRef. = vim.(
        editorRef.,
        vimStatusBarRef.
      );
      ();
    }
  }, [cleanupVim, monacoVim]);

   disableVim = ( {
    ();
    ();
  }, [cleanupVim]);

    = () => {
    editorRef. = editor;

    ( {
      editor.();

       model = editor.();
       (model) {
         lastLine = model.();
         (model.(lastLine).() !== ) {
          model.(
            [],
            [
              {
                :  monacoInstance.(
                  lastLine + ,
                  ,
                  lastLine + ,
                  
                ),
                : ,
                : ,
              },
            ],
             
          );
          lastLine += ;
        }
        editor.({
          : lastLine,
          : ,
        });
      }
    }, );

    editor.(
      monacoInstance..,
       () => {
         (!editorRef.) ;
         position = editorRef..();
         (!position) ;
         model = editorRef..();
         (!model) ;

         lineContent = model
          .(position.)
          .()
          .();

         (lineContent === ) {
          editorRef..();
          ;
        }
         (lineContent === ) {
           ();
        }
         (lineContent ===  || lineContent === ) {
          ();
        }
         output = [lineContent];
         (output) {
           edits = [
            {
              :  monacoInstance.(
                position. + ,
                ,
                position. + ,
                
              ),
              : output + ,
              : ,
            },
          ];
          model.([], edits,  );

           linesInserted = output.().;
          editorRef..({
            : position. + linesInserted + ,
            : ,
          });
        }  {
          editorRef..(, , { :  });
        }
      },
      
    );
  };

  ( {
      {
      ();
    };
  }, [cleanupVim]);

   (
    
  );
}

5. Use Your Editor in Any Page

src/app/page.tsx

tsx
import MdxEditor from "@/components/mdx-editor";

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

▊
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
editor
IStandaloneCodeEditor
null
null
const
HTMLDivElement
null
null
const
VimMode
null
null
const
useState
false
const
initVimMode
typeof
import
"monaco-vim"
initVimMode
null
null
// Lazy load monaco-vim
useEffect
() =>
if
typeof
window
"undefined"
import
"monaco-vim"
then
(vim) =>
setMonacoVim
// Cleanup Vim
const
useCallback
() =>
if
current
current
dispose
current
null
if
current
current
innerHTML
""
const
useCallback
async
cleanupVim
if
current
current
let
if
await
import
"monaco-vim"
setMonacoVim
current
initVimMode
current
current
setVimEnabled
true
const
useCallback
() =>
cleanupVim
setVimEnabled
false
const
handleEditorDidMount
editor: monaco.editor.IStandaloneCodeEditor, monacoInstance: typeof monaco
current
setTimeout
() =>
focus
const
getModel
if
let
getLineCount
if
getLineContent
trim
""
pushEditOperations
range
new
Range
1
1
1
1
text
"\n"
forceMoveMarkers
true
() =>
null
1
setPosition
lineNumber
column
1
0
addCommand
KeyCode
Enter
async
if
current
return
const
current
getPosition
if
return
const
current
getModel
if
return
const
getLineContent
lineNumber
trim
toLowerCase
if
"clear"
current
setValue
""
return
if
"vim"
await
enableVim
if
"novim"
"no vim"
disableVim
const
COMMANDS
if
const
range
new
Range
lineNumber
1
1
lineNumber
1
1
text
"\n"
forceMoveMarkers
true
pushEditOperations
() =>
null
const
split
"\n"
length
current
setPosition
lineNumber
lineNumber
1
column
1
else
current
trigger
"keyboard"
"type"
text
"\n"
""
useEffect
() =>
return
() =>
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>