Live demo: monaco-vim-green.vercel.app
Source: github.com/owolfdev/monaco-vim
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.
help, vim, or clearsrc
├── 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
npm install next@latest react react-dom @monaco-editor/react monaco-editor monaco-vim
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,
});
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>;
}
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>
);
}
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>
);
}
window is not defined) thanks to dynamic() and use client.