Skip to main content

Getting Started with Electron

This guide walks you through embedding a Zelt application in an Electron app.

Prerequisites

  • Node.js v20 or higher (or Bun v1.0 or higher)
  • A package manager: pnpm (recommended), npm, or bun

Additional requirements for Electron:

  • Electron — see @zeltjs/adapter-electron peer dependency for the minimum version
  • electron-vite (recommended)

Installation

pnpm add @zeltjs/core @zeltjs/adapter-electron
pnpm add -D electron electron-vite

Architecture Overview

Unlike server-based adapters (Node.js, Bun), the Electron adapter communicates via IPC instead of HTTP sockets. Your Zelt app runs in the main process, and the renderer calls it through an IPC bridge:

Renderer  ──ipcFetch()──▶  Preload  ──IPC──▶  Main (Zelt app)
              ◀── Response ──   ◀── Response ──

Three pieces are needed:

  1. Main processonElectron(app) starts Zelt and registers the IPC handler
  2. Preload scriptexposeIpc() bridges IPC to the renderer
  3. Renderer processipcFetch() sends requests through the bridge

Project Structure

my-electron-app/
├── src/
│   ├── main/
│   │   ├── entry/
│   │   │   └── hello.controller.ts
│   │   ├── app.ts           # Zelt app definition
│   │   └── index.ts         # Electron main entry
│   ├── preload/
│   │   └── index.ts         # IPC bridge setup
│   └── renderer/
│       └── src/
│           └── api/
│               └── zeltFetch.ts  # IPC fetch wrapper
├── electron.vite.config.ts
├── package.json
└── tsconfig.json

Hello World

Step 1: Create the Controller

Create src/main/entry/hello.controller.ts:

@Controller('/hello')
export class HelloController {
  @Get('/:name')
  greet(name = pathParam('name')) {
    return { message: `Hello, ${name}!` };
  }
}

Step 2: Create the Application

Create src/main/app.ts:

export const app = createApp([http({
    controllers: [HelloController],
  })]);

Step 3: Initialize in Main Process

Create src/main/index.ts:

const bootstrap = async () => {
  const electronZelt = await onElectron(app, {
    ipcChannel: 'http://zelt-app',
  });

  const win = new BrowserWindow({
    width: 900,
    height: 670,
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      contextIsolation: true,
      sandbox: true,
    },
  });
  win.loadFile(join(__dirname, '../renderer/index.html'));
};

void bootstrap();

onElectron() starts the Zelt runtime and automatically registers the IPC handler for the specified channel. It returns:

  • fetch(request) — Handles a Request and returns a Response
  • shutdown() — Gracefully shuts down the application
  • get<T>(Class) — Resolves a service from the DI container
important

The webPreferences.preload path must point to the compiled preload script. Without it, exposeIpc() never runs and the renderer cannot reach the Zelt app.

Step 4: Set Up the Preload Script

Create src/preload/index.ts:

exposeIpc({ channel: 'http://zelt-app' });

exposeIpc() uses Electron's contextBridge to safely expose the IPC sender to the renderer.

Step 5: Call API from Renderer

Create src/renderer/src/api/zeltFetch.ts:

export const zeltFetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
  ipcFetch(input, init, { channel: 'http://zelt-app' });

Use it like the standard fetch():

const response = await zeltFetch('http://zelt-app/hello/world');
const data = await response.json();
console.log(data.message); // "Hello, world!"
important

The channel string (e.g. 'http://zelt-app') must match across all three layers: main, preload, and renderer.

What's Next?