2025-07-13 Web Development
Building a Monaco Editor Terminal with Vim Mode in Next.js 15 (App Router)
By O. Wolfson
Live demo: monaco-vim-green.vercel.app
Source: github.com/owolfdev/monaco-vim
Overview
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.
- Monaco Editor: The engine behind VS Code, fully featured in the browser.
- Vim Mode: Power users get familiar navigation and editing.
- Terminal Chat Commands: Type commands like
help
orclear
, press Enter, and get output right in the editor. - Zero SSR Problems: Works with Vercel/static deployment.
Project Structure
src
├── app
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx // Main Monaco Terminal implementation
└── types
└── monaco-vim.d.ts // Minimal types for monaco-vim
1. Install Dependencies
bashnpm install next@latest react react-dom @monaco-editor/react monaco-editor monaco-vim
2. Dynamic MonacoEditor Import (SSR Safe!)
Monaco (and monaco-vim) depend on the browser.
Solution: Use Next.js dynamic()
with ssr: false
:
tsximport dynamic from "next/dynamic";
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
ssr: false,
});
3. Add Minimal monaco-vim
Type Declarations
monaco-vim
has no types—add this file:
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>;
}
4. The Full Editor Component
src/app/page.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!",
};
export default function HomePage() {
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);
// Load monaco-vim on mount
useEffect(() => {
if (typeof window !== "undefined" && !monacoVim) {
import("monaco-vim").then((vim) => {
setMonacoVim(vim);
});
}
}, [monacoVim]);
// Always clean up Vim mode before enabling/disabling
const cleanupVim = () => {
if (vimModeRef.current) {
vimModeRef.current.dispose();
vimModeRef.current = null;
}
if (vimStatusBarRef.current) {
vimStatusBarRef.current.innerHTML = "";
}
};
// Enable Vim mode (safe)
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);
}
}, [monacoVim]);
// Disable Vim mode (safe)
const disableVim = useCallback(() => {
cleanupVim();
setVimEnabled(false);
}, []);
// Handle commands, with robust Vim/novim switching
const handleEditorDidMount = (
editor: monaco.editor.IStandaloneCodeEditor,
monacoInstance: typeof monaco
) => {
editorRef.current = editor;
setTimeout(() => {
editor.focus();
}, 0);
editor.addCommand(
monacoInstance.KeyCode.Enter,
async () => {
if (!editorRef.current) return;
const value = editorRef.current.getValue();
const lines = value.split("\n");
const lastLine = lines[lines.length - 1].trim().toLowerCase();
if (lastLine === "clear") {
editorRef.current.setValue("");
return;
}
if (lastLine === "vim") {
await enableVim();
}
if (lastLine === "novim" || lastLine === "no vim") {
disableVim();
}
const output = COMMANDS[lastLine];
if (output) {
editorRef.current.setValue(value + "\n" + output + "\n");
const model = editorRef.current.getModel();
if (model) {
const lineCount = model.getLineCount();
editorRef.current.setPosition({
lineNumber: lineCount + 1,
column: 1,
});
}
} else {
editorRef.current.trigger("keyboard", "type", { text: "\n" });
}
},
""
);
};
// Cleanup on unmount
useEffect(() => {
return () => {
cleanupVim();
};
}, []);
const initialCode = `# MDX Editor Terminal
Type 'help', 'vim', or 'novim', then press Enter!
Or use the button below to toggle Vim mode.
`;
return (
<main
style={{ padding: 24 }}
className="bg-neutral-900 text-white h-screen"
>
<h2 className="text-2xl font-bold text-white mb-4">
MDX Editor (Monaco + Vim Toggle)
</h2>
<div className="h-10/12 mb-4">
<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}
disabled={false}
>
{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>
</main>
);
}
5. Why This Works on Vercel
- All browser-only code runs only on the client.
- No SSR build errors (“window is not defined”) thanks to
dynamic
and client-side-only imports. - TypeScript-friendly with a single
.d.ts
type file.
6. Next Steps & Ideas
- Add AI integration: Send code/questions to an API and append responses.
- Command history: Let users arrow up/down for past commands.
- Live preview: Render Markdown/MDX output beside the editor.
- Custom commands: Expand the
COMMANDS
map with more utilities.
Resources
Want a reusable <MonacoTerminalEditor />
component, or want to wire up real AI chat commands? Let me know!