SAFI.DEV
Back to Blog

Open-Source VS Code Extension Starters: React and Svelte with Tailwind

April 22, 2026 6 min read
Split screen showing a VS Code extension webview built with React on one side and Svelte on the other, both styled with TailwindCSS
Table of Contents

Setting up a VS Code extension with a modern webview is annoying.

Not hard — annoying. You have to stitch together esbuild, PostCSS, Tailwind, TypeScript, a routing layer, a message bridge between the extension host and the webview, Content Security Policy nonces, acquireVsCodeApi() typings, the VS Code contribution points in package.json… and the official yo code generator still gives you a vanilla TypeScript file with showInformationMessage('Hello World') as the only example. Useful in 2017. Not in 2026.

So I open-sourced the two templates I actually use when I start a new extension — one with React, one with Svelte — both fully wired and identical on the extension side.

Here's what you get:

  • github.com/Abdulkader-Safi/vs-code-extension-react-starter — React 19, React Router DOM 7, TailwindCSS v4, esbuild, TypeScript.
  • github.com/Abdulkader-Safi/vs-code-extension-svelte-starter — Svelte 5 (runes), svelte-spa-router, TailwindCSS v4, esbuild, TypeScript.

Both ship two working demo pages: a notification page that pushes messages from the webview into VS Code, and a directory listing page that pulls workspace data back the other way. That covers the two directions of webview messaging most extensions actually need.

Clone, rename, start building. That's the pitch.

Why Two Starters (and Not Just One)

When I was building ClarifAI, I started with React because that's what every VS Code webview tutorial uses. React is fine. It works. But the final packaged extension was heavier than I wanted for what was essentially a side panel.

Then I built a second extension where the UI was even smaller — a settings form, basically — and I tried Svelte. Bundle dropped by roughly 60%. Cold start felt snappier. And for a webview that lives inside someone's editor, cold start matters more than it does on a website.

So the real answer to "React or Svelte?" depends on what you're building:

  • React 19 → you already ship React, you need a specific React-only component (think data grids, PDF viewers, rich editors), or the webview will grow into a small SPA.
  • Svelte 5 → the UI is small-to-medium, you care about bundle size, or you want the least JavaScript shipped per panel load.

Both templates exist so you don't have to pick at setup time — you can clone either and the extension-host code is interchangeable.

What's Inside Each Starter

I'll walk through the React one first. The Svelte one is structurally identical — different framework, same scaffolding.

The Project Structure

src/
├── extension.ts           # VS Code extension entry point
├── WebviewProvider.ts     # Webview lifecycle + HTML + CSP
└── webview/
    ├── main.tsx           # React root
    ├── App.tsx            # Routing
    ├── pages/
    │   ├── Notification.tsx
    │   └── DirectoryListing.tsx
    └── styles/index.css   # Tailwind entry
dist/                      # esbuild output — this is what ships
esbuild.js                 # Single bundler config for both sides
tailwind.config.js
postcss.config.js
package.json

Two things to notice:

  1. One bundler, two builds. esbuild.js produces both the extension-side JS and the webview-side JS in a single run. No split webpack configs. No Vite-for-webview-but-tsc-for-extension. Just esbuild.
  2. Clean boundary. Everything under src/webview/ is the UI. Everything outside is the VS Code extension. They talk only via postMessage. This is the boundary that prevents a whole category of "why did importing this break my activation?" bugs.

The Extension Side

extension.ts is tiny — it registers two commands and launches the webview:

export function activate(context: vscode.ExtensionContext) {
  context.subscriptions.push(
    vscode.commands.registerCommand('react-starter.helloWorld', () => {
      vscode.window.showInformationMessage('Hello from React Starter');
    }),
    vscode.commands.registerCommand('react-starter.openReactView', () => {
      WebviewProvider.render(context);
    })
  );
}

WebviewProvider.ts is where the actual work happens. It creates the webview panel, loads the compiled JS/CSS from dist/, injects a Content Security Policy with a nonce (the thing most tutorials skip and then wonder why scripts don't execute), and wires up the message listener:

panel.webview.onDidReceiveMessage(async (message) => {
  switch (message.type) {
    case 'show-notification':
      vscode.window.showInformationMessage(message.text);
      return;
    case 'list-files':
      const files = await vscode.workspace.findFiles('**/*', '**/node_modules/**');
      panel.webview.postMessage({ type: 'files-result', files });
      return;
  }
});

That pattern — discriminated-union messages by type — scales all the way up. ClarifAI uses the same pattern with 14 message types and it's still readable.

The Webview Side

This is the part that's different between the two templates. In the React version, main.tsx mounts App, App.tsx defines routes with React Router, and each page calls vscode.postMessage() and listens on window for responses.

The trick I ended up bundling into both templates is a tiny hook (or Svelte action, on the other side) that wraps the message-listener lifecycle:

// useVSCodeMessage.ts
export function useVSCodeMessage<T>(type: string, handler: (data: T) => void) {
  useEffect(() => {
    const listener = (event: MessageEvent) => {
      if (event.data?.type === type) handler(event.data);
    };
    window.addEventListener('message', listener);
    return () => window.removeEventListener('message', listener);
  }, [type, handler]);
}

Ten lines. Never write that boilerplate again.

The Svelte equivalent is even shorter because runes handle the cleanup implicitly via $effect.

TailwindCSS v4 Inside a Webview (The Non-Obvious Part)

Tailwind v4 moved to a PostCSS-first architecture, which sounds like a small thing until you realize the old tailwindcss init + content: [] config doesn't exist anymore. For a VS Code webview, you want:

  1. @tailwindcss/postcss as a PostCSS plugin.
  2. Your CSS entry file imports @import "tailwindcss"; — that's it.
  3. esbuild runs PostCSS as part of the build via a plugin.

Both starters ship this configured. If you clone, npm install, npm run compile, and press F5, the webview opens with Tailwind working. No extra setup.

This is also where Svelte gets a quiet win — Svelte 5's scoped CSS composes cleanly with Tailwind utilities, so you can use class: directives and utility classes together without the specificity wars you sometimes hit in a React app.

Running It Locally (Three Steps)

After cloning either repo:

npm install
npm run watch

That starts esbuild in watch mode for both the extension and the webview. Then in VS Code, press F5 to open the Extension Development Host — a second VS Code window where your extension is loaded. In that window, open the Command Palette (Cmd+Shift+P / Ctrl+Shift+P) and run "Open React View" or "Open Svelte View" depending on which template you cloned.

The webview opens. The demo pages work. You've got a running extension in under 60 seconds from git clone.

When you're ready to ship, npm run package produces a production build, and vsce package turns it into a .vsix you can publish to the marketplace. I covered the publishing flow in detail in Building VS Code Extensions in 2026.

What's Deliberately Missing

A few things I left out on purpose, because every extension's needs differ:

  • State management. Both templates use local component state. If your extension grows into a real SPA, drop in whatever you already like. For Svelte, I've written up why Zustand can make sense even there — but for most extensions, writable stores or React's useState is enough.
  • Theme syncing. VS Code's light/dark/high-contrast themes expose CSS variables to webviews. Tailwind v4 makes this easy to consume, but the exact mapping depends on your design, so the templates leave it unopinionated.
  • Authentication, API keys, secrets. Use VS Code's SecretStorage API, not globalState. That's extension-specific — I wasn't going to hard-code a pattern.
  • Tests beyond the scaffold. @vscode/test-cli is wired up and runs a trivial sanity test. Real testing is a per-project decision.

The goal is a clean starting point, not a kitchen sink. I'd rather you delete three files than hunt through fifteen layers of abstraction to find the one thing you actually want to change.

When to Use Which Starter (Honest Take)

Here's the heuristic I actually use:

Situation Pick
Webview is a small-to-medium form or panel Svelte
You need a specific React library (react-flow, react-pdf, ag-grid) React
Your team ships React across all products React
Bundle size under 100KB matters Svelte
You want to learn Svelte 5 runes in a real project Svelte
You'll hand off to a team of 10+ devs Whichever they already know

Both ship today. Both will keep working. And because the extension-host layer is identical, I can port a project between them in about two hours if I guess wrong.

What to Do Now

  1. Clone the one that fits your project:
    • React: git clone https://github.com/Abdulkader-Safi/vs-code-extension-react-starter
    • Svelte: git clone https://github.com/Abdulkader-Safi/vs-code-extension-svelte-starter
  2. Rename the extension. Update name, displayName, and the command IDs in package.json. Five minutes.
  3. Delete the two demo pages once you understand them — they're there to show the messaging pattern, not to ship.
  4. Open an issue on either repo if something's broken or missing. I use these templates every time I start a new extension, so PRs that genuinely improve them land fast.

If you want more context on the bigger picture of extension development — marketplace publishing, activation events, the contribution API — my longer guide Building VS Code Extensions in 2026 picks up where these starters leave off. And if you want to see two extensions I shipped using this exact stack, check out ClarifAI and the file explorer I built because macOS Finder is painful.

Star the repos if they save you an afternoon. That's the only payment I'll ask for

Abdulkader Safi
Software Engineer

Building scalable systems and exploring web/mobile development. Passionate about developer experience and platform engineering.

GET IN TOUCH

Let's Build Something Together

Whether you have a project idea, want to collaborate on a web or mobile app, or just say hello

Get in Touch safi.abdulkader@gmail.com