July 12, 2025
O. Wolfson
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
bashnpm 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:
tsx// in mdx-editor.tsx
import dynamic from "next/dynamic";
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
ssr: false,
});
Create src/types/monaco-vim.d.ts:
tsdeclare 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
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]);
(
);
}
src/app/page.tsx
tsximport 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>
);
}
window is not defined) thanks to dynamic() and use client.