Claude Code with Monaco Editor: VS Code in Your App, Done Right
Why Monaco without CLAUDE.md ships editors with leaked workers and broken IntelliSense
Monaco is the editor that powers VS Code, repackaged for the browser. It is a full language-aware code editor: syntax highlighting, IntelliSense, multi-cursor, find and replace, minimap, the lot. The price for that capability is operational complexity. Monaco runs language services in web workers, holds editor models that survive component unmount, and registers themes and languages on a global registry that does not get garbage-collected. Get the setup wrong and the page leaks workers, IntelliSense never appears, the editor renders white because the theme was never registered, or hot module reload throws because the language was already registered on the previous mount.
Claude Code does not know any of this by default. It generates import * as monaco from 'monaco-editor' and monaco.editor.create(el, { value, language }) and stops. The result works for a single editor in a static page and breaks the moment the app has routing, multiple editor instances, hot reload, or a bundler that does not auto-configure workers. The most common Claude defaults that break Monaco in production: workers loaded from a CDN path that breaks behind a corporate proxy, no MonacoEnvironment so the editor falls back to the main thread for language services, editors created without monaco.editor.createModel so each instance owns its own state, disposables not tracked so the editor leaks DOM nodes and workers on unmount, themes used by name before they are registered with defineTheme, IntelliSense providers registered on every mount so duplicate completions stack up, and Vite imports that bundle the entire VS Code language service for every page load.
This guide covers the CLAUDE.md configuration that locks Claude Code into Monaco's worker-correct, disposable-clean model: MonacoEnvironment declared once before any editor is created, workers imported via the bundler's worker syntax, models created with monaco.editor.createModel and reused across editor mounts, disposables tracked in a per-editor array and released in the cleanup, themes registered with defineTheme before any editor uses them, language services registered once at module scope, and Vite or Webpack workers configured via the recommended bundler plugin. For the React component patterns Monaco depends on, Claude Code with React covers the lifecycle rules that prevent leaked editor instances.
The Monaco CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Monaco integration it needs to declare: the Monaco version line, the bundler and worker setup, the language services in use, the theme registration policy, the model lifecycle rules, the IntelliSense pattern, and the hard rules that block the mistakes Claude makes most often.
# Monaco Editor rules
## Stack
- monaco-editor ^0.x via npm (NEVER from CDN in production)
- Bundler: Vite + vite-plugin-monaco-editor OR webpack + monaco-editor-webpack-plugin
- Workers: language workers loaded via bundler-specific worker syntax
- @monaco-editor/react for React bindings (optional, see Editor wrapper section)
## Project structure
- src/monaco/ , Monaco setup and configuration
- src/monaco/setup.ts , MonacoEnvironment + worker registration
- src/monaco/themes/ , theme definitions registered via defineTheme
- src/monaco/languages/ , custom language registrations
- src/monaco/providers/ , IntelliSense providers (completion, hover, signature)
- src/monaco/models.ts , model factory + disposal tracking
- src/components/Editor.tsx , React wrapper around monaco.editor.create
## Worker setup (MANDATORY before first editor creation)
- self.MonacoEnvironment = { getWorker(_, label) { ... } }
- import each worker via the bundler's worker syntax (?worker for Vite)
- Labels handled at minimum: editorWorkerService, json, css, html, typescript
- NEVER omit MonacoEnvironment, the fallback runs language services on main thread
## Model lifecycle (MANDATORY)
- ALWAYS create models via monaco.editor.createModel(value, language, uri)
- Reuse models across editor mounts when the URI is the same
- Dispose models with model.dispose() when the document is closed
- NEVER pass value + language as create options without creating an explicit model
- ALWAYS use a stable monaco.Uri per logical document
## Disposables (MANDATORY)
- Track every disposable in an array per editor instance
- Release in the cleanup with editor.dispose() AFTER each tracked disposable
- editor.onDidChangeModelContent returns a disposable, capture and release
- Language service registrations are global, do NOT track per-editor, register ONCE
## Theme registration (MANDATORY)
- monaco.editor.defineTheme(name, themeData) BEFORE editor.create with that theme
- Define themes at module scope, NOT inside the component
- NEVER pass a theme name that has not been defined
## Language services (when extending)
- Register completion / hover / signature providers ONCE at module scope
- ALWAYS scope providers to the language ID, NEVER register on '*'
- Provider order matters, last registered wins for same trigger
## Bundler integration (MANDATORY)
- Vite: import 'vite-plugin-monaco-editor' and add to plugins, list languages
- Webpack: import MonacoWebpackPlugin and add to plugins, list languages
- NEVER import * as monaco from 'monaco-editor' without bundler plugin (ships 5MB)
## Hard rules
- NEVER load Monaco from a CDN in production (cache poisoning + version drift)
- NEVER omit MonacoEnvironment, the editor silently falls back to main thread
- NEVER create an editor without an explicit model
- NEVER skip dispose() on editor unmount (leaks DOM + worker + memory)
- NEVER register a language service provider per-mount (duplicates stack)
- NEVER use a theme name before it has been defined
- NEVER share a monaco.Uri across editors that hold different documents
- ALWAYS track disposables and release them in the cleanup
Five rules here prevent the majority of incidents Claude generates without them.
The worker setup rule prevents the silent fallback to the main thread that turns TypeScript IntelliSense from instant to 800ms-blocking. Monaco loads language workers via the MonacoEnvironment.getWorker hook. If the hook is missing, Monaco falls back to running the language services in the main thread, which blocks rendering on every keystroke in a typed language. Claude omits MonacoEnvironment because it is not in the obvious example code. The rule "MANDATORY before first editor creation" makes the setup the first thing in any Monaco file.
The model lifecycle rule prevents two opposite bugs: editors that share a model when they should not, and editors that recreate the model when they should reuse it. A model is the document state: text, language, decorations, marker overlays. Two editors pointing at the same model show the same content and edits sync between them, which is what you want for a split-view editor and not what you want for two tabs of different files. The rule "always create models via createModel with a stable URI" makes the URI the identity that controls sharing.
The disposables rule prevents the worker leak that crashes the browser tab after 50 editor mounts. Every Monaco editor, every model, every language service registration, every event listener returns a disposable. Without explicit dispose() calls the underlying DOM nodes, workers, and event subscribers stay live forever. The rule "track every disposable in an array per editor, release in cleanup" makes the lifecycle explicit.
The theme registration rule prevents the white-screen bug where the editor renders without colours because the theme name was not in the registry. monaco.editor.defineTheme('my-theme', {...}) mutates a global registry. editor.create(el, { theme: 'my-theme' }) looks up the registry. If the second runs before the first, Monaco silently falls back to a built-in theme and the custom colours never appear. The rule "defineTheme BEFORE editor.create" makes the ordering explicit.
The language service registration rule prevents the IntelliSense duplication that shows the same completion item three times. Providers are registered on a global registry per language. A React component that registers a completion provider in useEffect registers a new one on every mount. With hot reload, every save adds another. The rule "register ONCE at module scope" moves the registration out of the component lifecycle.
Install and worker setup
Install Monaco and the bundler plugin. For Vite:
pnpm add monaco-editor vite-plugin-monaco-editor
Configure the plugin in vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import monacoEditorPlugin from 'vite-plugin-monaco-editor';
export default defineConfig({
plugins: [
react(),
monacoEditorPlugin({
languageWorkers: ['editorWorkerService', 'json', 'css', 'html', 'typescript'],
}),
],
});
The plugin emits the language workers as separate bundles and rewrites worker imports to point at them.
Set up MonacoEnvironment at module scope before any editor creation:
// src/monaco/setup.ts
import * as monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
self.MonacoEnvironment = {
getWorker(_workerId: string, label: string) {
switch (label) {
case 'json':
return new jsonWorker();
case 'css':
case 'scss':
case 'less':
return new cssWorker();
case 'html':
case 'handlebars':
case 'razor':
return new htmlWorker();
case 'typescript':
case 'javascript':
return new tsWorker();
default:
return new editorWorker();
}
},
};
export { monaco };
Three things in this snippet that the CLAUDE.md rule enforces. The ?worker suffix is Vite's worker import syntax, which produces a constructor that spawns the worker. The getWorker hook covers the canonical labels Monaco's services emit. The monaco import is re-exported so application code imports from a single setup module that has the side effect of registering MonacoEnvironment.
Defining themes before editor creation
A theme is a colour scheme plus a set of token rules. Monaco ships two built-in themes (vs and vs-dark) and lets you register more.
// src/monaco/themes/dracula.ts
import * as monaco from 'monaco-editor';
monaco.editor.defineTheme('dracula', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'comment', foreground: '6272a4', fontStyle: 'italic' },
{ token: 'keyword', foreground: 'ff79c6' },
{ token: 'string', foreground: 'f1fa8c' },
{ token: 'number', foreground: 'bd93f9' },
{ token: 'type', foreground: '8be9fd' },
{ token: 'function', foreground: '50fa7b' },
],
colors: {
'editor.background': '#282a36',
'editor.foreground': '#f8f8f2',
'editor.lineHighlightBackground': '#44475a',
'editorCursor.foreground': '#f8f8f2',
'editor.selectionBackground': '#44475a',
},
});
Import the theme file from your setup module so the side effect runs on app load:
// src/monaco/setup.ts
import './themes/dracula';
// ... rest of setup
Add a theme rule to CLAUDE.md:
## Theme definition (CONCRETE)
monaco.editor.defineTheme(name, {
base: 'vs' | 'vs-dark' | 'hc-black' | 'hc-light',
inherit: true,
rules: [{ token, foreground, fontStyle? }],
colors: { '<scope>': '#rrggbb' },
});
ALWAYS: defineTheme at module scope, imported by setup.ts
ALWAYS: defineTheme runs BEFORE the first editor.create that uses the theme
NEVER: pass a theme name to editor.create that has not been defined
Models with stable URIs
A Monaco model holds the document state. Two editors with the same model show the same content. Two editors with different models show different content.
A model factory that reuses models by URI:
// src/monaco/models.ts
import * as monaco from 'monaco-editor';
const modelCache = new Map<string, monaco.editor.ITextModel>();
export function getOrCreateModel(
uri: monaco.Uri,
value: string,
language: string,
): monaco.editor.ITextModel {
const key = uri.toString();
const cached = modelCache.get(key);
if (cached && !cached.isDisposed()) return cached;
const model = monaco.editor.createModel(value, language, uri);
modelCache.set(key, model);
return model;
}
export function disposeModel(uri: monaco.Uri) {
const key = uri.toString();
const model = modelCache.get(key);
if (model && !model.isDisposed()) {
model.dispose();
modelCache.delete(key);
}
}
Use the factory in the editor wrapper so the same document URI maps to the same model:
// src/components/Editor.tsx
'use client';
import { useEffect, useRef } from 'react';
import * as monaco from 'monaco-editor';
import '@/monaco/setup';
import { getOrCreateModel } from '@/monaco/models';
type Props = {
documentUri: string;
initialValue: string;
language: string;
theme?: string;
onChange?: (value: string) => void;
};
export function Editor({
documentUri,
initialValue,
language,
theme = 'vs-dark',
onChange,
}: Props) {
const hostRef = useRef<HTMLDivElement | null>(null);
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
useEffect(() => {
if (!hostRef.current) return;
const uri = monaco.Uri.parse(documentUri);
const model = getOrCreateModel(uri, initialValue, language);
const editor = monaco.editor.create(hostRef.current, {
model,
theme,
automaticLayout: true,
fontSize: 14,
tabSize: 2,
minimap: { enabled: false },
});
editorRef.current = editor;
const subscriptions: monaco.IDisposable[] = [];
if (onChange) {
subscriptions.push(
model.onDidChangeContent(() => onChange(model.getValue())),
);
}
return () => {
for (const s of subscriptions) s.dispose();
editor.dispose();
editorRef.current = null;
};
}, [documentUri, language, theme]);
return <div ref={hostRef} style={{ width: '100%', height: '100%' }} />;
}
Four patterns in this wrapper that the CLAUDE.md rule enforces. The model is fetched via getOrCreateModel so two editors with the same URI share state. The editor is created with the model object, not a value + language shortcut, so the lifecycle is explicit. The content change listener is captured as a disposable and released in cleanup. The automaticLayout: true option avoids the most common Monaco bug: an editor that does not resize with its container.
For the TypeScript discipline that catches model URI mismatches at compile time, Claude Code with TypeScript covers the type patterns that work well with Monaco's nominally-typed Uri objects.
Custom completion providers
A completion provider supplies IntelliSense suggestions. Register at module scope, scoped to a specific language.
// src/monaco/providers/sql-completions.ts
import * as monaco from 'monaco-editor';
const keywords = ['SELECT', 'FROM', 'WHERE', 'GROUP', 'BY', 'ORDER', 'JOIN', 'ON', 'INSERT', 'UPDATE', 'DELETE'];
const functions = ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'DATE_TRUNC', 'CURRENT_DATE'];
monaco.languages.registerCompletionItemProvider('sql', {
triggerCharacters: [' ', '.', '('],
provideCompletionItems(model, position) {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const suggestions: monaco.languages.CompletionItem[] = [
...keywords.map((kw) => ({
label: kw,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: kw,
range,
})),
...functions.map((fn) => ({
label: fn,
kind: monaco.languages.CompletionItemKind.Function,
insertText: `${fn}($1)`,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
})),
];
return { suggestions };
},
});
Three patterns the CLAUDE.md rule enforces. The registration runs once at module scope, not inside a React effect. The provider is scoped to the 'sql' language, not '*'. The range is computed from the current word, so the suggestion replaces only the partial token instead of inserting alongside it.
Add a provider rule to CLAUDE.md:
## Completion providers
- Register with monaco.languages.registerCompletionItemProvider('<lang>', { ... })
- triggerCharacters: characters that trigger automatic suggestion
- provideCompletionItems returns { suggestions: [{ label, kind, insertText, range }] }
- range MUST be computed from getWordUntilPosition for correct replacement
- ALWAYS scope to a specific language, NEVER use '*'
- Register ONCE at module scope, NEVER inside a component
Common Claude Code mistakes with Monaco
Six patterns Claude generates incorrectly without CLAUDE.md constraints.
1. Missing MonacoEnvironment
Claude generates monaco.editor.create(el, { value: 'console.log(1)', language: 'typescript' }) and stops. TypeScript IntelliSense never appears because the worker did not load. The fallback runs the language service on the main thread, blocking renders.
Correct pattern: import setup.ts first, which sets MonacoEnvironment with the worker factory.
2. Editor created without an explicit model
Claude generates monaco.editor.create(el, { value, language, theme }). Monaco implicitly creates a model behind the scenes. The model has no URI, cannot be looked up later, and disposing the editor disposes the model so reopening the same document loses state.
Correct pattern: getOrCreateModel(uri, value, language) then monaco.editor.create(el, { model, theme }).
3. Disposables not released
Claude generates an editor and listens for content changes:
const editor = monaco.editor.create(host, { model, theme });
editor.onDidChangeModelContent(() => onChange(model.getValue()));
The listener is registered but the disposable is dropped. On unmount the editor is disposed but the listener subscription leaks. With 20 editor mounts per session, the page accumulates 20 dead subscriptions that all retain onChange references.
Correct pattern: capture the disposable in an array, release every entry plus the editor in the cleanup.
4. Theme used before defined
Claude generates the editor with theme: 'dracula' and writes monaco.editor.defineTheme('dracula', ...) later in the component file. The first editor mount uses a fallback theme because the registration runs after create.
Correct pattern: defineTheme at module scope, imported by setup.ts, before any editor file imports the editor wrapper.
5. Language provider in a React effect
Claude generates:
useEffect(() => {
monaco.languages.registerCompletionItemProvider('sql', provider);
}, []);
Strict-mode double-mount registers the provider twice. Hot reload registers it again on every save. After 10 saves the suggestion list shows duplicates.
Correct pattern: registration at module scope, idempotent by virtue of running once.
6. Bare import * as monaco from 'monaco-editor' without bundler plugin
Claude generates the import without configuring vite-plugin-monaco-editor or monaco-editor-webpack-plugin. The bundler includes every language service, every theme, and every keybinding registry. The bundle balloons to 5 MB.
Correct pattern: install the bundler plugin, list only the languages and features the app uses, ship a 600 KB bundle.
Add this list to CLAUDE.md with the corrected form for each.
Permission hooks for Monaco projects
A Monaco project has CLI surface for installing packages and running tests. Permission hooks gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(pnpm add monaco-editor*)",
"Bash(pnpm add @monaco-editor/*)",
"Bash(pnpm test*)"
],
"deny": [
"Bash(pnpm remove monaco-editor*)",
"Bash(rm -rf src/monaco*)"
]
}
}
Building Monaco editors that load fast and stay responsive
The Monaco CLAUDE.md in this guide produces editors where MonacoEnvironment is set before the first editor creation, workers load via the bundler's worker syntax so language services run off the main thread, every editor uses an explicit model with a stable URI, every disposable is tracked and released, themes are defined at module scope before any editor uses them, language service providers are registered once globally with a language-specific scope, and the bundler ships only the languages and features the app actually uses.
The underlying principle is the same as any heavyweight client-side framework with Claude Code. Monaco without a CLAUDE.md produces editors that work in a single-page demo and degrade in real apps: workers fail to load behind corporate proxies, language services run on the main thread because MonacoEnvironment was forgotten, IntelliSense duplicates because providers re-registered on every mount, editors leak DOM nodes because disposables were not tracked, and the bundle ships 5 MB of language services the app never touches.
For the React component patterns the editor wrapper depends on, Claude Code with React covers the rendering model that pairs cleanly with Monaco's imperative lifecycle. For the build configuration that keeps the Monaco bundle small, Claude Code with Vite covers the worker bundling rules that pair with vite-plugin-monaco-editor. Claudify includes a Monaco-specific CLAUDE.md template with the worker setup, the model factory, the disposable tracking pattern, the theme registration order, the completion provider scaffolding, and all six common-mistake rules pre-configured.
Get Claudify. Ship Monaco editors that load workers and respect disposables.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify