How to set up Vite and Electron from scratch, with any frontend framework
Tags: vite, electron, typescript
Published: 2022-04-17 16:20:56

Do you like **Vite** and want to create an **Electron** project using it?

Do you also want to learn how to set up everything by yourself from scratch?

Good news, everyone! I got you covered, in this article I will explain in details, how to get started using **Electron** in **Vite** from scratch, with no boilerplate, no plugins (in fact you will create one on your own following this article) and using just tools that you may have in your toolbox.

### TLDR;

> Actually read I promise you it will be worth it, do not scroll to the bottom where there is a link to GitHub with the solution ready to go...

If you want to use **Vue**, **React**, **Svelte** or any other frontend framework, don't worry, the process is the same.

Less abstraction and more power to you so lets'go!

> In this article I am gonna use `pnpm`, feel free to use your preferred package manager, **Electron Builder** [recommend](https://www.electron.build/index.html#installation) using yarn

## Scaffolding Vite

Following the official documentation for [Scaffolding Your First Vite Project](https://vitejs.dev/guide/#scaffolding-your-first-vite-project), let's create a new `vue-ts` project by running:

```bash
pnpm create vite electron-vite -- --template vue-ts
```

And remember you can use any template you want, the process is practically the same.

Cd into your project folder or just open **VSCode**:

```bash
cd electron-vite
```

And remember to install the dependencies:

```bash
pnpm i
```

> If you are not a fan of TypeScript, it is used very sparingly in this article, and you can totally avoid it, just remember to replace all `.ts` with `.js`.

## Scaffolding Electron

We are now ready to set up **Electron**.

Create a file `src-electron/main.ts`, it will be our electron entry point:

```ts
// src-electron/main.ts
import { app, BrowserWindow } from 'electron'

let mainWindow: BrowserWindow | undefined

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    useContentSize: true
  })

  mainWindow.loadURL('http://localhost:3000')
  mainWindow.webContents.openDevTools()

  mainWindow.on('closed', () => {
    mainWindow = null
  })
}

app.whenReady().then(createWindow)

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

app.on('activate', () => {
  if (mainWindow == null) {
    createWindow()
  }
})
```

Do not worry about the hard-coded location for now, I will show you how to make everything dynamic later on, and also, do not worry too much about setting up Electron perfectly so early on, let's build our way up first.

We are forgetting something, let's install electron as a dev dependency locally:

```bash
pnpm i electron -D
```

## Combining Vite and Electron

Create a new Vite config named `vite.config.electron.ts` it will help us to understand what we are doing while serving practical purposes later on:

```ts
// vite.config.electron.ts
import { defineConfig } from 'vite'

export default defineConfig({
  publicDir: false,
  build: {
    ssr: 'src-electron/main.ts'
  }
})
```

When building in **Electron**, we do not need the public directory, so we turn it off with `publicDir: false`.

We also need to tell Vite to build an ssr target, Electron runs on **Node** and not on the browser, which is what Vite targets by default, `build.ssr: 'src-electron/main.ts'` tells Vite just that.

Add a new script in your package.json (just make a new one, do not delete the ones already present):

```json
  "scripts": {
    "build:electron": "vite build -c vite.config.electron.ts",
  },
```

This will tell Vite to build using the config we have just created, as stated in the official Vite documentation [Config File Resolving](https://vitejs.dev/config/#config-file-resolving).

And let's run the script to see what we got:

```bash
pnpm build:electron
```

If everything went the right way, you should see something like this in your terminal:

![Electron Build Output](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qdkiot5jt8klqwwum7bt.png)
_Material Theme and [Terminal Zoom](https://marketplace.visualstudio.com/items?itemName=trybick.terminal-zoom) VSCode extension makes such beautiful high quality images_

To run electron, let's add another script to your package.json:

```json
  "scripts": {
    "dev:electron": "electron dist/main.js",
    "build:electron": "vite build -c vite.config.electron.ts",
  },
```

The new script `dev:electron` will run electron using our freshly build `main.js`.

Make sure you run `pnpm build:electron` prior, otherwise Electron will fail to start.

## Dev server with Electron

Finally, let's bring everything together, we still have more to do but for now, let's enjoy Vite and Electron working together.

Start Vite dev server with:

```bash
pnpm vite
```

And lets take a closer look at the output:

![Vite Dev Server Output](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3a1csv5ixtlnarofqt5s.png)

Do you notice something? Yes that's the address we have used to [Scaffolding Electron](#scaffolding-electron), at the start of this article, `http://localhost:3000` is in fact the address of the Vite dev server.

So let's take a break from the code to really understand what we are doing here.

Electron acts very similarly to a regular web browser, so what we have done until now is really just setting up a toolchain that allow us to build and start electron pointing at our Vite dev server address.

Now you have probably guessed it, start electron in to a separate terminal with:

```bash
pnpm build:electron
pnpm dev:electron
```

And that's it, we now have a fully functional Electron application with Vite:

![Electron Vite Final Result](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/702qtd1c0528w2vzna10.png)

## Recap Until this point

We went through tons of stuff so lets make a quick recap before proceeding further:

1. [Scaffold a Vite Project](https://vitejs.dev/guide/#scaffolding-your-first-vite-project)

```bash
pnpm create vite electron-vite -- --template vue-ts
```

2. Create a `src-electron/main.ts` file

```ts
// src-electron/main.ts
import { app, BrowserWindow } from 'electron'

function createWindow() {
  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600
  })

  // poin to vite dev server port
  mainWindow.loadURL('http://localhost:3000')
  mainWindow.webContents.openDevTools()
}

app.whenReady().then(createWindow)
```

3. Create a new Vite config `vite.config.electron.ts`

```ts
// vite.config.electron.ts
import { defineConfig } from 'vite'

export default defineConfig({
  publicDir: false, // dont publish the public dir
  build: {
    // electron run on node, not on the browser
    ssr: 'src-electron/main.ts'
  }
})
```

4. Add new package.json scripts

```json
  "scripts": {
    "dev:electron": "electron dist/main.js",
    "build:electron": "vite build -c vite.config.electron.ts",
  }
```

5. Install electron and build

```bash
pnpm i electron
```

```bash
pnpm build:electron
```

6. Run the project

```bash
pnpm vite & pnpm dev:electron
```

Or use two separate terminal and run `pnpm vite` and `pnpm dev:electron`

## Make everything automatic with a Vite plugin written from scratch

As a right now, we have some annoyance we want to get rid of, first of all, it is really boring setting everything up manually, and also if we make a change to the electron main file, sadly nothing reloads automatically.

Let's fix most of our problems leveraging Vite and Rollup.

Edit `vite.config.ts`, do not edit the electron config, make sure you are editing the default Vite config:

```ts
// vite.config.ts
import { resolve } from 'path'
import { spawn, type ChildProcess } from 'child_process'
import type { ViteDevServer } from 'vite'
import { defineConfig, build } from 'vite'
import vue from '@vitejs/plugin-vue'

async function bundle(server: ViteDevServer) {
  // this is RollupWatcher, but vite do not export its typing...
  const watcher: any = await build({
    // our config file, vite will not resolve this file
    configFile: 'vite.config.electron.ts',
    build: {
      watch: {} // to make a watcher
    }
  })

  // it returns a string pointing to the electron binary
  const electron = require('electron') as string

  // resolve the electron main file
  const electronMain = resolve(
    server.config.root, 
    server.config.build.outDir, 
    'main.js'
  )

  let child: ChildProcess | undefined

  // exit the process when electron closes
  function exitProcess() {
    process.exit(0)
  }

  // restart the electron process
  function start() {
    if (child) {
      child.kill()
    }

    child = spawn(electron, [electronMain], {
      windowsHide: false
    })

    child.on('close', exitProcess)
  }

  function startElectron({ code }: any) {
    if (code === 'END') {
      watcher.off('event', startElectron)
      start()
    }
  }

  watcher.on('event', startElectron)

  // watch the build, on change, restart the electron process
  watcher.on('change', () => {
    // make sure we dont kill our application when reloading
    child.off('close', exitProcess)

    start()
  })
}

export default defineConfig({
  plugins: [
    vue(), // only if you are using vue
    // this is a vite plugin, configureServer is vite-specific
    {
      name: 'electron-vite',
      configureServer(server) {
        server.httpServer.on('listening', () => {
          bundle(server).catch(server.config.logger.error)
        })
      }
    }
  ]
})
```

Run the Vite dev server and lets see some magic:

```bash
pnpm vite
```

> You can also use `dev` if you have setup the project from a Vite template

Electron starts as soon Vite is ready to serve our application, not only that, but electron will automatically restart when we make a change to `src-electron/main.ts`, pretty neat.

What we have created is simply a way to build and restart electron automatically, now we could delete our utility scripts as they are not needed anymore, however they are might still be useful for debugging and tinkering.

There is something more we need to do, Electron uses relative path to load files, but Vite uses the [base](https://vitejs.dev/config/#base) option, which is `/` by default, therefore our application will not work when we actually build the Vite project.

To fix this problem lest edit the Vite config:

```ts
// vite.config.ts

// ... the rest of the code, scroll down
export default defineConfig((env) => ({
  // nice feature of vite as the mode can be set by the CLI
  base: env.mode === 'production' ? './' : '/',
  plugins: [
    vue(), // only if you are using vue
    {
      name: 'electron-vite',
      configureServer(server) {
        server.httpServer.on('listening', () => {
          bundle(server).catch(server.config.logger.error)
        })
      }
    }
  ]
}))
```

This will make the base path relative, Electron will resolve your assets correctly, as an example, the following is how the index.html file should look like when you run `vite build`:

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="./favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
    <script type="module" crossorigin src="./assets/index.js"></script>
    <link rel="stylesheet" href="./assets/index.css">
  </head>
  <body>
    <div id="app"></div>
    
  </body>
</html>
```

As you can see, everything is prefixed with `./`.

## Conclusions

Owww boys and girls this has been quite a fun, hopefully you now have a better understanding on Electron, Vite and how to go about making a plugin to tackle a problem.

Start from the ground up and build your way up, like a skyscraper!

And still, as I promise, we have not used a single plugin and instead, we have created one from scratch!

Ok wait a second, I have also promised to make the electron url fully dynamic.

And wait, what about building the project? And what about dev and production? And what about the preload scripts?

## Have we still not have done yet?

Ok lets jump right back into the action.

We now have an automatic way to run electron, so lets improve upon that.

Starting by updating the electron main file:

```ts
import { app, BrowserWindow } from 'electron'

let mainWindow: BrowserWindow | undefined

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    useContentSize: true
  })
  
  // when in dev mode, load the url and open the dev tools
  if (import.meta.env.DEV) {
    mainWindow.loadURL(import.meta.env.ELECTRON_APP_URL)
    mainWindow.webContents.openDevTools()
  } else {
    // in production, close the dev tools
    mainWindow.webContents.on('devtools-opened', () => {
      mainWindow.webContents.closeDevTools()
    })

    // load the build file instead
    mainWindow.loadFile(import.meta.env.ELECTRON_APP_URL)
  }

  // ... the rest of the code
```

We are now using `import.meta.env.DEV` which comes from Vite, but also a new env variable that does not exists `import.meta.env.ELECTRON_APP_URL`.

Update your `vite.config.ts`:

```ts
// vite.config.ts
import { type AddressInfo } from 'net'
import { resolve } from 'path'
import { spawn, type ChildProcess } from 'child_process'
import type { ViteDevServer } from 'vite'
import { defineConfig, build } from 'vite'
import vue from '@vitejs/plugin-vue'

async function bundle(server: ViteDevServer) {
  // resolve the server address
  const address = server.httpServer.address() as AddressInfo
  const host =
    address.address === '127.0.0.1'
      ? 'localhost'
      : address.address

  // build the url
  const appUrl = `http://${host}:${address.port}`

  // this is RollupWatcher, but vite do not export its typing...
  const watcher: any = await build({
    configFile: 'vite.config.electron.ts',

    // mode is `development` when running vite 
    // mode is `production` when running vite build
    mode: server.config.mode,

    build: {
      watch: {} // to make a watcher
    },
    define: {
      // here we define a vite replacement
      'import.meta.env.ELECTRON_APP_URL': JSON.stringify(appUrl)
    }
  })

  // ... the rest of the code
```

Using the [define](https://vitejs.dev/config/#define) config option in Vite, we can replace any string in our code with a specific value.

And remember that, the electron file is written on disk, it is located at `dist/main.js`, so you can inspect the file, so let's do it right now:

```js
// dist/main.js
"use strict";
var electron = require("electron");
let mainWindow;
function createWindow() {
  mainWindow = new electron.BrowserWindow({
    width: 800,
    height: 600,
    useContentSize: true
  });
  {
    mainWindow.loadURL("http://localhost:3000");
    mainWindow.webContents.openDevTools();
  }
  mainWindow.on("closed", () => {
    mainWindow = null;
  });
}
electron.app.whenReady().then(createWindow);
electron.app.on("window-all-closed", () => {
  if (process.platform !== "darwin") {
    electron.app.quit();
  }
});
electron.app.on("activate", () => {
  if (mainWindow == null) {
    createWindow();
  }
});
```

That's the content of the file generated by Vite, it is a CJS module compatible with Node, and it is pretty much what you would have written by hand, but created by Vite!

> Bonus tip, you can set `build.minify: true` to minify Electron main file.

## Bundling and building Electron

For bundling Electron, we are going to use [Electron Builder](https://www.electron.build/), so lets install it:

```bash
pnpm i electron-builder -D
```

For now, since we are not using any dependency for Electron, do not worry about `.npmrc`, you can deal with it later.

Update your `package.json` `main` and `build` like so:

```json
  "main": "dist/main.js",
  "build": {
    "files": [
      "./dist/**/*"
    ]
  },
```

This will instruct Electron Builder to use `dist/main.js` and to also pack all the files under the `dist` folder.

We now need a way to build Vite and Electron together, so lets hack something that works and then, we are going to clean everything up later.

Update the Vite config for electron:

```ts
// vite.config.electron.ts
import { defineConfig } from 'vite'

export default defineConfig({
  publicDir: false,
  build: {
    // we build on top of vite, do not delete the generated files
    emptyOutDir: false,
    ssr: 'src-electron/main.ts'
  },
  define: {
    // once again
    'import.meta.env.ELECTRON_APP_URL': JSON.stringify('index.html')
  }
})
```

Basically we tell Vite to not delete the out dir, `dist` by default, since we are going to build Vite first, then Electron on the same folder.

So with everything ready to go let's build Electron.

Build Vite first:

```bash
pnpm vite build
```

Then build Electron, hopefully you haven't deleted the script we have created a couple of chapters ago:

```bash
pnpm build:electron
```

Or the equivalent command:

```bash
pnpm vite build -c vite.config.electron.ts
```

Check the `dist` folder, it should contain your Vite application, alongside the Electron `main.js` file:

```
dist
├── favicon.ico
├── index.html
├── main.js
├── assets
│   ├── .js, .css
```

Then build with `electron-builder`, just like you normally would:

```bash
pnpm electron-builder
```

And that's it, you have build your first and surely not last Electron application using Vite!

Pat yourself on your back, we still need to cover some topics, so buckle up we are about to pick up some speed!

## Preload Script

Context isolation is a big topic per se, you can read more on the official documentation at [electron.js](https://www.electronjs.org/docs/latest/tutorial/context-isolation)

In short, in Electron, the render thread, which is your frontend, do not have access to the Node API.

A preload script allows you to set up a communication between Node and your frontend, it is quite easy to setup, edit the `src-electron/main.ts` file to use the preload script:

```ts
// src-electron/main.ts
import { resolve } from 'path'

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    ...
    webPreferences: {
      contextIsolation: true,
      // preload scripts must be an absolute path
      preload: resolve(__dirname, 'preload.js')
    }
  })
```

Create the preload file `src-electron/preload.ts`, it will run under **Node** so you can use any node package and function you want:

```ts
// src-electron/preload.ts
import fs from 'fs'
import { contextBridge } from 'electron'

contextBridge.exposeInMainWorld('readSettings', function () {
  return JSON.parse(fs.readFileSync('./settings.json', 'utf8'))
})
```

ContextBridge basically allow us to do this:

```ts
// in your frontend
const settings = window.readSettings()
```

The Electron function `exposeInMainWorld`, add a function to the window object named `readSettings` in this case.

If you want typings, you need to create them manually:

```ts
// src/types.d.ts
interface Window {
  readSettings: () => Record<string, any>
}
```

Or you can declare them locally:

```ts
declare global {
  interface Window {
    readSettings: () => Record<string, any>
  }
}
```

You can get pretty advanced with typings, or they can get pretty messy, but for now, let's leave it that way.

Edit the Electron Vite config:

```ts
// vite.config.electron.ts
import { defineConfig } from 'vite'

export default defineConfig({
  publicDir: false,
  build: {
    emptyOutDir: false,
    ssr: true, // true means, use rollupOptions.input
    rollupOptions: {
      // the magic, we can build two separate files in one go!
      input: ['src-electron/main.ts', 'src-electron/preload.ts']
    }
  },
  define: {
    'import.meta.env.ELECTRON_APP_URL': JSON.stringify('index.html')
  }
})
```

And thats it, simply run:

```bash
pnpm dev
```

And we're done!

## Troubleshooting

Be sure to use relative path for your assets files:

```ts
export default defineConfig((env) => ({
  base: env.mode === 'production' ? './' : '/'
}))
```

Use an absolute path for the preload script:

```ts
webPreferences: {
  preload: path.resolve(__dirname, './preload.js')
}
```

For fine tuning what Electron can or cannot load, you need to intercept the `file:///` protocol, see the [official Electron documentation](https://github.com/electron/electron/blob/main/docs/api/protocol.md) for more.

Electron Builder do not resolve packages with pnpm see [Note for PNPM](https://www.electron.build/index.html#note-for-pnpm) at the official Electron Builder documentation.

Electron Builder bundle all the dependencies, so you should put everything that is not used in Electron into devDependencies.

## Conclusions... Again

From this point onwards, you can start cleaning up the project if you intend to use it later on, as a starting template.

I encourage you to commit your work with git before moving forward, and start by removing `vite.config.electron.ts`, infact, you have already created a plugin for your project that bundle Electron using almost the same configuration.

If you compare `vite.config.electron.ts` and `vite.config.ts`, they are doing almost the same thing, so you could actually embed one into the other.

## Source on GitHub

The surce code from this article is available on github

[https://github.com/lucacicada/vite-electron-from-scratch](https://github.com/lucacicada/vite-electron-from-scratch)

## There is a Plugin for it

If you are looking for a Vite plugin that does pretty much all of this and more, well guess what, I have created a plugin that does just that!

Head over [github.com/armoniacore/armonia-vite](https://github.com/armoniacore/armonia-vite)

There is a [starter template on GitHub](https://github.com/armoniacore/starter-template-vue-ts)

And a less bloated [playground on GitHub](https://github.com/armoniacore/armonia-vite/tree/main/packages/playground/electron-vue)

---

If you are still with me after this wall of text, your awesome!

Do not forget to share opinion, suggestions, critiques or just say hi in the comment section below!

Ciao!