DEV Community

Cover image for How to use React with a VSCode webview.
Qiyuan Chen
Qiyuan Chen

Posted on

How to use React with a VSCode webview.

TL;DR: Turn VSCode Webviews into complex React apps with hot reload in development and a secure bundle-based setup in production.

Hot Reload Showcase

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

  1. Scaffold the extension
  2. Create the React app
  3. Fix TS project boundaries
  4. Wire up the Webview panel
  5. Processing the Vite generated HTML
  6. Vite configuration
  7. Run in development
  8. Build & publish
  9. What's next
  10. Github Implementation

Scaffold the extension

First install yeoman and the VS Code extension generator:

npm install --global yo generator-code
yo code
Enter fullscreen mode Exit fullscreen mode

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"
      }
    ]
  },
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • enableScripts: true lets your React bundle run.
  • localResourceRoots whitelists 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 with webview.asWebviewUri, and apply a strict CSP (no unsafe-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,
    );
}
Enter fullscreen mode Exit fullscreen mode

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,
    }
  };
});
Enter fullscreen mode Exit fullscreen mode

Run in development

In one terminal:

cd webview
npm run dev
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_PORT and read it in both webview/vite.config.ts and src/getWebviewContent.ts.

  • Wire up one-click build & debug: Modify the scripts in package.json, .vscode/tasks.json and .vscode/launch.json so 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)