TL;DR: Turn VSCode Webviews into complex React apps with hot reload in development and a secure bundle-based setup in production.
What you'll build
- A VS Code extension whose Webview is a React + TypeScript app.
- HMR (Fast Refresh) in development via Vite.
- A Content Security Policy (CSP) safe production build.
Table of contents
- Scaffold the extension
- Create the React app
- Fix TS project boundaries
- Wire up the Webview panel
- Processing the Vite generated HTML
- Vite configuration
- Run in development
- Build & publish
- What's next
- Github Implementation
Scaffold the extension
First install yeoman and the VS Code extension generator:
npm install --global yo generator-code
yo code
When prompted, choose:
- New Extension (TypeScript)
- Bundler: Unbundled (we’ll handle UI bundling separately with Vite)
- Package manager: npm
Fill in the other fields (name, identifier, description, etc.) as you like. In this tutorial I will use the project name create-react-webview.
This gives you a base extension with the usual scaffolding, including a registered command in package.json.
{
"contributes": {
"commands": [
{
"command": "create-react-webview.helloWorld",
"title": "Hello World"
}
]
},
}
Create the React app
From the extension’s root, scaffold a React + TS (SWC) app using Vite:
npm create vite@latest
cd webview
npm install
When prompted, choose:
- Project name: webview
- Select a framework: React
- Select a variant: TypeScript + SWC
- Use rolldown-vite: No
Your current project structure should look like this (essentials only):
create-react-webview/
├─ package.json
├─ tsconfig.json
├─ src/
│ └─ extension.ts
└─ webview/
├─ package.json
├─ tsconfig.json
├─ vite.config.ts
└─ src/
├─ main.tsx
└─ App.tsx
Fix TypeScript project boundaries
Open your tsconfig.json file in the root of your project and change the include to only the src directory to prevent the root project from accidentally compiling webview/**:
{
"compilerOptions": {
// your existing options…
},
"include": ["src/**/*"] // IMPORTANT
}
Wire up the Webview panel
src/extension.ts:
import * as vscode from "vscode";
import * as path from "node:path";
import { getWebviewContent } from "./getWebviewContent"; // Currently unimplemented
export function activate(context: vscode.ExtensionContext) {
const disposable = vscode.commands.registerCommand(
"create-react-webview.helloWorld",
async () => {
const panel = vscode.window.createWebviewPanel(
"reactWebview",
"React Webview",
vscode.ViewColumn.One,
{
enableScripts: true,
localResourceRoots: [
vscode.Uri.file(path.join(context.extensionPath, "webview", "dist"))
]
}
);
panel.webview.html = await getWebviewContent(context, panel.webview);
}
);
context.subscriptions.push(disposable);
}
Key points:
-
enableScripts: truelets your React bundle run. -
localResourceRootswhitelists where the static files can be read from in production.
Processing the Vite generated HTML
We create getWebviewContent, which is a function that generates the HTML content for our VSCode webview panel, adapting its output for development or production environments.
- Dev: load scripts from a local Vite server (Fast Refresh + HMR)
- Prod: read the built
webview/dist/index.html, rewrite asset URLs withwebview.asWebviewUri, and apply a strict CSP (nounsafe-eval)
src/getWebviewContent.ts:
import { randomUUID } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import * as vscode from "vscode";
const PORT = 5174;
interface DevUris {
refreshUri: vscode.Uri;
clientUri: vscode.Uri;
entryUri: vscode.Uri;
origin: string;
wsOrigin: string;
}
function createBaseCSP(webview: vscode.Webview): string[] {
return [
`default-src 'none'`,
`img-src ${webview.cspSource} https: data:`,
`style-src ${webview.cspSource} 'unsafe-inline'`,
`font-src ${webview.cspSource} https:`,
`frame-src ${webview.cspSource} https:`,
];
}
function createDevCSP(
webview: vscode.Webview,
nonce: string,
origin: string,
wsOrigin: string,
): string {
return (
[
...createBaseCSP(webview),
`script-src 'nonce-${nonce}' 'unsafe-eval' ${origin}`,
`connect-src ${origin} ${wsOrigin} ws://localhost:${PORT} ws://127.0.0.1:${PORT}`,
].join("; ") + ";"
);
}
function createProdCSP(webview: vscode.Webview, nonce: string): string {
return (
[
...createBaseCSP(webview),
`script-src 'nonce-${nonce}' ${webview.cspSource}`,
`connect-src ${webview.cspSource}`,
].join("; ") + ";"
);
}
async function getDevUris(): Promise<DevUris> {
const refreshLocal = vscode.Uri.parse(
`http://localhost:${PORT}/@react-refresh`,
);
const clientLocal = vscode.Uri.parse(`http://localhost:${PORT}/@vite/client`);
const entryLocal = vscode.Uri.parse(`http://localhost:${PORT}/src/main.tsx`);
const [refreshUri, clientUri, entryUri] = await Promise.all([
vscode.env.asExternalUri(refreshLocal),
vscode.env.asExternalUri(clientLocal),
vscode.env.asExternalUri(entryLocal),
]);
const origin = `${clientUri.scheme}://${clientUri.authority}`;
const wsOrigin = origin.replace(/^http/, "ws");
return { refreshUri, clientUri, entryUri, origin, wsOrigin };
}
function createDevHTML(
nonce: string,
uris: DevUris,
csp: string,
): string {
return `
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="${csp}">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dev</title>
</head>
<body>
<div id="root"></div>
<script type="module" nonce="${nonce}">
import RefreshRuntime from "${uris.refreshUri.toString(true)}";
RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
</script>
<script type="module" nonce="${nonce}" src="${uris.clientUri.toString(true)}"></script>
<script type="module" nonce="${nonce}" src="${uris.entryUri.toString(true)}"></script>
</body>
</html>
`;
}
function processProductionHtml(
htmlContent: string,
webview: vscode.Webview,
distPath: string,
nonce: string,
): string {
const processedHtml = htmlContent.replace(
/(href|src)=["']([^"']*)["']/g,
(match, attr, url) => {
if (
url.startsWith("http") ||
url.startsWith("data:") ||
url.startsWith("#") ||
url === ""
) {
return match;
}
const clean = url.replace(/^\//, "");
const onDisk = vscode.Uri.file(path.join(distPath, clean));
const webviewUri = webview.asWebviewUri(onDisk).toString();
return `${attr}="${webviewUri}"`;
},
);
const csp = createProdCSP(webview, nonce);
return processedHtml
.replace(
"</head>",
`<meta http-equiv="Content-Security-Policy" content="${csp}"></head>`,
)
.replace(
/<script([^>]*)type="module"([^>]*)>/g,
`<script$1type="module"$2 nonce="${nonce}">`,
);
}
export async function getWebviewContent(
context: vscode.ExtensionContext,
webview: vscode.Webview,
): Promise<string> {
const isDev = context.extensionMode === vscode.ExtensionMode.Development;
const nonce = randomUUID();
if (isDev) {
const uris = await getDevUris();
const csp = createDevCSP(webview, nonce, uris.origin, uris.wsOrigin);
return createDevHTML(nonce, uris, csp);
}
const htmlPath = path.join(
context.extensionPath,
"webview",
"dist",
"index.html",
);
const distPath = path.join(context.extensionPath, "webview", "dist");
const htmlContent = fs.readFileSync(htmlPath, "utf8");
return processProductionHtml(
htmlContent,
webview,
distPath,
nonce,
);
}
It's ok if you don't fully understand how the above code works, since we simply use it as a black box in src/extension.ts.
Vite configuration
Configure the port and keep wide-open CORS dev-only. The port number here must match the PORT variable defined in src/getWebviewContent.ts:
webview/vite.config.ts:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
export default defineConfig(({ command }) => {
const port = 5174
return {
plugins: [react()],
server: command === "serve" ? {
port,
strictPort: true,
// wide-open CORS (dev-only)
cors: { origin: "*" },
headers: { "Access-Control-Allow-Origin": "*" }
} : undefined,
build: {
assetsInlineLimit: 10000,
}
};
});
Run in development
In one terminal:
cd webview
npm run dev
You should see Vite listening on http://localhost:5174.
In VSCode, press F5 (or "Run Extension"). This will launch a new Extension Development Host window (a separate VSCode instance). In this new window, run the Hello World command from the Command Palette.
You should see your React app in a Webview with Fast Refresh.
Build & publish
Run the following:
cd webview
npm run build
This compiles your extension and builds the webview to webview/dist.
You can now follow the official VS Code guide to package and publish your extension. Link
What's next
Make the dev port configurable: Use an environment variable like
WEBVIEW_DEV_PORTand read it in bothwebview/vite.config.tsandsrc/getWebviewContent.ts.Wire up one-click build & debug: Modify the scripts in
package.json,.vscode/tasks.jsonand.vscode/launch.jsonso F5 builds the webview and launches the extension.Typed messaging with an RPC layer: Introduce a small RPC bridge for host ⇄ webview calls (with input validation and response typing). A guide to do so will be coming in the next blog post.
Integrate common React tooling: For example, add React Query for caching/data fetching and Tailwind CSS for fast styling.
Github Implementation
Get the complete working template here on Github: QiyuanChen02/react-webview
If you found this guide helpful, consider ⭐️ starring the repo. It would make my day.

Top comments (0)