Building and publishing desktop applications with Electron and Next.js


May 2024 update: part two of this series is now available! Keep an eye out for part three sometime in July 2024. You should also join the ⚡️ waitlist to be the first to know when I release a pre-packaged, shippable Electron starter kit containing everything I've learned building these apps and writing these articles.

We're going to build and publish a desktop application using Electron and familiar web technologies like Next.js and Tailwind CSS. It'll show a feed of commits from git repositories you choose on your local computer.

This article is part one of a planned three part series because there's way too much to cram into one post. In this part we'll develop, build and even publish a basic version of the application shown above. Part two adds more functionality and some aesthetic polish, and in part three (coming soon) we'll look at some release hardening best practices and source code protection.

I'm sure Electron development in 2023 easier than it ever has been, but I found the process frustratingly fragmented: lots of tools doing similar things in different ways, often opaque and incomplete ecosystem documentation, and a lack of contemporary writing on the subject. This article and the series it will eventually form a part of is my miniscule contribution back to the community, and I hope it helps you. Note that I develop on a Macbook so the distribution part of the article only covers macOS, but hopefully the rest is useful regardless of your target OS.

Background: why Electron?

I've used the same tech stack for each of the SaaS products I've launched so far this year since exploring my options when I started working on Feedback Rocket back in February:

I can't recommend this combination enough if you want to get things done, and you want to get things shipped.

My latest launch is a little bit different: it's a desktop application. Supasight lets you manage unlimited Supabase users across unlimited Supabase projects from a single place, through a single pane of glass. It's arguably my first real painkiller this year, and came about as a result of my own struggles managing multiple Supabase projects - the Supabase projects behind my five other launches so far.

Supasight could have been another SaaS product, but it relies on admin API keys which:

  • I did not want to take responsibility for storing remotely
  • I did not expect customers to trust anyone else to store remotely

As such, I knew that I did not want storage of these keys to leave a user's computer, where I could delegate thorny things like encryption to the operating system, built by people who know a lot more about it than I do and battle tested by millions of people. I'd been looking for an excuse to try out Electron for a while, and this was it. The question was: how much of the stack I'd become so fond of could I bring with me?


The first thing I did to answer that question was Google something like "electron nextjs tailwind", which turns up a few results ultimately pointing at Nextron. I had a quick look through the examples before spotting with-tailwindcss, which doesn't just bundle Tailwind but also uses TypeScript. That's the top half of the tech stack already - I'm sold.

Getting started

Installing the Nextron template which bundles Tailwind is easy:

npx create-nextron-app nextron-buildalong --example with-tailwindcss
✔ Validating existence...
✔ Downloading and extracting...

Done! Run `npm install && npm run dev` inside of "/Users/np/code/nextron-buildalong" to start the app

Running npm install and npm run dev as instructed yields this:

What is Nextron?

If you look hard enough you'll find plenty of Electron template starter kits, and Nextron is one of them. It's a preconfigured, opinionated Electron app which uses Next.js for the Electron renderer process. It saves you from making the same mistakes, getting caught by the same edge cases, and setting up the same boilerplate as those who've gone before, and provides some sensible defaults to get you up and running quickly.

In development mode, npm run dev starts two processes:

  • A vanilla Next.js development server (allowing all the usual things like hot reloading, etc)
  • An Electron process, which loads the localhost URL of the Next.js development server into a browser window upon startup

Production builds bundle a Next.js Static Export into the distributed application, which are loaded into the Electron window using electron-serve - no external server required.

Do I need to use Nextron with Electron?

Absolutely not. I'd encourage you to explore other options, especially if another framework or tooling combination is more familiar to you. Even if you do use Nextron, I strongly encourage you to familiarise yourself with the moving parts and how they fit together. I've seen too many people raising basic isues on the Nextron GitHub repository which ultimately stem from a lack of understanding of its component parts.

For the rest of this article, I'm going to assume Nextron, Tailwind and TypeScript are a good fit for you.

Updating Next.js

NOTE: as of April 2024 Nextron v8.15.0 ships with Next.js 13.5.6, so you can skip the following paragraph.

Until recently Nextron bundled outdated versions of certain mission-critical dependencies. Most are now up-to-date, but at the time of writing you'll still be running Next.js 12.x. There is no good reason not to upgrade to Next.js 13, though it's worth pointing out that we will NOT be using the App Router: we'll stick to the Pages Router instead. The upgrade is worth it for this fix alone, and besides, Next.js 12.x is old!

npm i next@latest

The example home.tsx and next.tsx pages won't work anymore, but we don't need them. Delete renderer/pages/next.tsx, and replace home.tsx with something like this:

export default function Home() {
  return (
    <div className="text-3xl text-center">
      Hello, Electron!

While we're at it, let's clear out some boilerplate Tailwind and CSS configuration which came with the Nextron template. Replace the contents of renderer/styles/globals.css with:

@tailwind base;
@tailwind components;
@tailwind utilities;

And strip out the theme: { ... } definition from renderer/tailwind.config.js, too:

module.exports = {
  content: [
  plugins: [],

Going beyond the basics

So far, so good. But also so familiar. Nothing we couldn't do inside a normal browser, and nothing like the screenshot at the top of this article. Let's start building something a bit more interesting. Our application will allow users to:

  • Select folders on their filesystem representing one or more git repositories
  • Show a feed of the most recent commits across all repositories, in date order - a bit like a Twitter timeline for nerds 🤓

To keep the article managable, that's all the app will do. It would be pretty easy to make it do a lot more, like allowing users to pull changes from remote repositories and updating the user's feed accordingly. Feel free to give that a go! In part two we'll add navigation between pages and a Context Provider to preserve state between page transitions.

For now, it's time to start thinking about the APIs and implementation details which will let us achieve the very basic functionality outlined above.

The Electron process model

I'm not going to go into much detail about Electron's process model here; instead I'll refer you to the official Electron Process Model documentation. The important thing to understand is that our application is actually made up of two separate processes:

  • The main process, which governs the application lifecycle including opening and closing windows, has access to the host file system and can run anything you'd run in a typical Node.js process. You can think of this as the backend process: imagine this is your web server with privileged access to important resources
  • The renderer process, which runs inside each browser window (in our case, only one). You can think of this as the frontend process: imagine this is your web page, which shouldn't be trusted to run important code

I'm using backend and frontend here to help guide your thinking as to which process should be responsible for what, but take the analogy with a pinch of salt. Though it's running in a desktop app, you should assume your renderer process is as vulnerable to snooping and tampering as a web page running inside an ordinary browser. Clearly then, we probably want to do most of the heavy lifting in the main process. In keeping with good practice, we want to expose the minimum surface area possible to the renderer from the main process via a well-defined API, in the same way we choose to expose an API between front and backend web services.

Setting up Inter-Process Communication (IPC)

But what does this API look like? Our main process isn't running a web server, so we can't just make HTTP requests to it. Instead, we can leverage Electron's Inter-Process Communication mechanism to shuttle messages between the processes. We're only going to make use of two of the available IPC methods, which allow requests to be issued from the renderer and responses to be issued from the main process:

  • ipcMain.handle(eventName, ...args) - this will act as a handler in the main process: it will wait to be called, perform some work and return a response
  • ipcRenderer.invoke(eventName, ...args) - this will invoke a handler in the main process from the renderer process, and will return any response from the handler

You don't need to understand the low-level details of IPC, but it is at least worth familiarising yourself with how objects are serialized when passed between processes. As long as you only ever try and pass data around, you shouldn't have to think about this too much.

If you take a step back and squint a bit, you should see that these paradigms aren't too dissimilar to the request/response lifecycle in the web world. Other available IPC methods like .send() and .on() are useful for one-way communication (like streaming), but we won't use them today.

Exposing a minimal API to the renderer process

You can't just import the ipcRenderer object directly inside your Next.js code, because it relies on privileged filesystem access which the renderer is not granted. Instead, we have to expose a bridge between the processes, and it is this bridge which will act as our API layer. Setting this up is one of the less intuitive aspects of Electron development but luckily for us Nextron comes with a basic preload script which exposes an ipc object to the renderer process and even exports a type definition for it, meaning you get IDE auto-complete and a level of type safety when calling the API from your Next.js code.

Nextron's preload script is a little too abstract and permissive for my tastes, so we're going to replace it with our own which defines a more concrete API:

// ./main/preload.ts
const { contextBridge, ipcRenderer } = require("electron")

const handler = {
  selectRepositories: () => ipcRenderer.invoke("select-repositories"),
  getRecentCommits: () => ipcRenderer.invoke("get-recent-commits"),
  // part two:
  // viewRepository: (id: number) => ipcRenderer.invoke("view-repository", id),

contextBridge.exposeInMainWorld("ipc", handler)

export type IpcHandler = typeof handler

However complex your own applications get, I wouldn't recommend doing anything other than the minimum amount of work possible in your preload script. Be disciplined: don't be tempted to expose the ipcRenderer object directly, don't run any business logic, and don't expose a 'catch-all' method which allows the renderer to invoke any method in the main process: all that does is unnecessarily increase the attack surface for a potential bad actor.

Calling the API

The preload script creates a global ipc object which we can now access from our Next.js renderer code via window.ipc. We're going to completely replace the contents of home.tsx with quite a lot of code, but nothing too complicated. First up, we need to install a new dependency, heroicons, which we'll use to liven up some of our application:

npm i --save-dev @heroicons/react

Wait - why --save-dev? Remember: when we build our application for distribution, all code and assets needed for the renderer process are statically exported. This can be confusing at first, but it's easy enough to keep track of: anything you need in the main process must be installed as a regular dependency. Anything you only need in the renderer can be a development dependency.

Now we're ready to replace the contents of home.tsx:

import { useState } from 'react'
import { ChatBubbleOvalLeftEllipsisIcon, CodeBracketIcon, ExclamationCircleIcon, PlusIcon, RssIcon, } from '@heroicons/react/24/outline'
import Image from 'next/image'

export default function Home() {
  const [repositories, setRepositories] = useState([])
  const [commits, setCommits] = useState([])

  const selectRepositories = async () => {
    const repos = await window.ipc.selectRepositories()

    const commits = await window.ipc.getRecentCommits()

  return (
    <div className="overflow-x-hidden">
      <div className="w-64 fixed bg-stone-900 flex flex-col inset-y-0 ">
        <h1 className="h-10 font-semibold text-gray-300 flex items-center justify-end border-b border-stone-800 mr-3">
          <div className="flex items-center gap-1">
            Chomp Git <ChatBubbleOvalLeftEllipsisIcon className="h-6 w-6" />
        <div className="flex grow flex-col bg-stone-900 overflow-y-auto px-3.5 mt-3">
          <div className="text-sm font-semibold leading-6 text-gray-400 flex items-center px-1">
            <CodeBracketIcon className="h-5 w-5 mr-2" /> Repositories
              onClick={() => selectRepositories()}
              className="ml-auto h-4 w-4 text-gray-400 hover:text-gray-200 ring-1 rounded-sm ring-gray-400 hover:ring-gray-200"
          <ul className="mt-5 ml-1 space-y-2 text-xs">
            {, i) => (
              <li key={i} className="flex items-center text-gray-400 hover:text-gray-200 justify-between">
                <div className="flex gap-1 items-center">
                  {!repo.isClean && <ExclamationCircleIcon className="h-4 w-4 text-yellow-400/40" />}
                  {repo.hasRemote && <RssIcon className="h-4 w-4 text-green-400/40" />}
      <div className="pl-64 mx-3.5 my-2">
        <div className="space-y-1.5">
          {, i) => (
            <div key={i} className="flex text-sm align-text-top">
              <div className="shrink-0 flex gap-1 items-center">
                  <Image src={`${commit.avatarHash}.jpg?d=robohash`} width={20} height={20} className="rounded-full ring-1 ring-gray-500/10" alt="" />
                {, 10)}
              <div className="ml-1 w-full flex justify-between">
                <span className="font-semibold truncate ">{commit.message}</span>
                <span className="mx-1 inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10">

If you now run the application you'll see a skeleton frame of the UI we're building. The icon to select repositories won't work yet and will in fact throw an error because select-repositories isn't implemented yet.

Implementing the API

To keep things simple, I'm going to keep all of the business logic we implement one file: main/handlers.ts. In your own projects, feel free to separate your code as you would with any well-structured, maintainable application.

// ./main/handlers.ts
import { dialog, ipcMain } from 'electron'
import simpleGit from 'simple-git'
import crypto from 'crypto'

// we're going to store our repository data in memory for demo purposes
const repositories = []

export const bindHandlers = () => {
  ipcMain.handle('select-repositories', async () => {
    const { filePaths } = await dialog.showOpenDialog({ properties: ['openDirectory', 'multiSelections'] })

    for (const path of filePaths) {
      if (repositories.find(repository => === path)) continue
      try {
        const git = simpleGit(path)
        const repository = {
          name: path.split('/').slice(-1),
          hasRemote: (await git.getRemotes()).length > 0,
          isClean: (await git.status()).isClean(),
          commits: (await git.log()).all,
      } catch (e) {
        // probably not a git repository; ignore

    return{ name, hasRemote, isClean }) => ({ name, hasRemote, isClean }))

  ipcMain.handle('get-recent-commits', async () => {
    const commits = []
    for (const repository of repositories) {
      for (const commit of repository.commits) {
          avatarHash: crypto.createHash('md5').update(commit.author_email).digest('hex'),
    return commits.sort((a, b) => new Date( - new Date(, 500)

Note how we only return a subset of each repository's properties in the response from select-repositories. This is fairly aribtrary in our contrived example since we immediately then call get-recent-commits from the renderer, but you should always consider what data to return from each API call.

We've introduced a new dependency on simple-git in the main process here, so we need to install that:

npm i simple-git

And we need to actually call the bindHandlers function we just defined:

// ./main/background.ts

await app.whenReady()

const mainWindow = createWindow('main', {
  width: 1000,
  height: 600,



// the dev tools get pretty annoying, so let's comment that out, too:

// mainWindow.webContents.openDevTools()


With that, you should be able to fire up the application and select one or more folders containing git repositories from your local file system. With a few selected, you should see something like this:

And with that, we've got the makings of of a desktop application! We're not going to make a full-blown git client, but there's no reason you couldn't build one.

You'll notice there is no page navigation in our application, purely to keep this article manageable. Navigating between pages works exactly as in a normal Next.js application, which means by default you'd lose any state you might want to keep around when doing so. In our example, the state we want to preserve is the list of repositories shown in the sidebar.

In part two, we solve this problem using a React Context Provider, and implement navigation between pages to give our app more interactivity and purpose.

Making things more native

This is another area explored in more detail in part two of this series, but in the meantime I would highly recommend spending some time reading Vadim Demendes' Making Electron apps feel native on Mac article, which is a great primer on the subject. For now, we're just going to fix one glaring issue: the rather ugly default window title bar. Pop open main/background.ts and add a new option when creating the window:

const mainWindow = createWindow('main', {
  width: 1000,
  height: 600,
  titleBarStyle: 'hiddenInset',
  webPreferences: { ... }

That alone makes a huge difference. We explore Vadim's recommendations and a host of other small improvements which add up to a much more native feeling application in part two.

Packaging the application

With that done, it's time to ship! Nextron bundles electron-builder, which helps take a lot of the pain out of building assets suitable for distribution on Windows, macOS and Linux. For the rest of this article I'm only going to focus on building and distributing for macOS, because that's the only platform I've shipped Supasight on so far.

A note on security

We have a responsibility to our users to make sure the application we're asking them to download and run on their computer is as secure as possible. We can't just lean on the security of the browser sandbox here: our main process has access to the user's file system, so we need to ensure we've taken steps to mitigate any malicious exploitation. No code is ever going to be perfect, but we should at least check our application against the Electron Security Checklist.

It turns out we're doing pretty well against this list already. There are really only four applicable recommendations we could implement:

  • Define a Content Security Policy
  • Disable or limit navigation
  • Disable or limit creation of new windows
  • Validate the sender of all IPC messages

We're not going to add these enhancements today, but you should consider doing so in any of your own applications you intend to distribute - always keep security at the forefront of your mind as you would when building any type of application. To bring back the web analogy again, treat the user's computer as you would a business-critical server, except with even more care and attention, because it's theirs, not yours.

Building the application

Much like running the application in development, Nextron provides a lightweight wrapper around other tools when packaging your application for distribution. It creates a static export of the "frontend" application, builds the main "backend" process, and then invokes electron-builder to package everything up into a set of distributable artifacts. Let's run the build process:

npm run build

> my-nextron-app@1.0.0 build
> nextron build

[nextron] Clearing previous builds
[nextron] Building renderer process


[nextron] Building main process


[nextron] Packaging - please wait a moment


[nextron] See `dist` directory

I've omitted most of the output here, highlighting only the distinct parts of the build process. When it completes, we'll end up with a bunch of assets in the dist directory:

ls -lh dist/                                                                                                                                  main
total 395432
-rw-r--r--  1 np  staff    83M 13 Oct 12:45 My Nextron
-rw-r--r--  1 np  staff    89K 13 Oct 12:45 My Nextron
-rw-r--r--@ 1 np  staff    86M 13 Oct 12:44 My Nextron App-1.0.0-arm64.dmg
-rw-r--r--  1 np  staff    93K 13 Oct 12:44 My Nextron App-1.0.0-arm64.dmg.blockmap
-rw-r--r--  1 np  staff   775B 13 Oct 12:45 builder-debug.yml
-rw-r--r--  1 np  staff   247B 13 Oct 12:44 builder-effective-config.yaml
drwxr-xr-x  3 np  staff    96B 13 Oct 12:44 mac-arm64/

Lurking in the mac-arm64 directory is a binary which we can run to test our application:

And voila! We have a desktop application we can click on rather than run from a terminal! But we're still not quite done yet.

Signing the application

You'd be forgiven for thinking we're good to go: we've got zip files we can share and even a DMG installer to properly install our application on macOS which will feel very familiar and very native to our users. But Apple have a few more hoops for us to jump through before we can distribute it beyond our own computer.

Code signing

The good news is that electron-builder makes this as close to configuration-free as possible. The bad news is that you'll need to be a member of the Apple Developer Program and there is some unavoidable one-time faff to provision certificates used to sign your application.

Signing up for the Apple Developer Program and generating the certificates you need has been well covered by others, so I'm going to leave it to them (edit: in truth, this is a good example of fragmented and outdated documentation - I may end up writing a part four of this series). I recommend following the steps in this article up to the "Download & install XCode" heading, which we'll come back to when we tackle Notarization in a moment. Once you've got the certificates installed in your keychain, the next time you run npm run build electron-builder will auto-detect your certificates and sign your application for you. If you see any output relating to signing (which will take a couple of minutes), you're set up correctly:

[nextron] Packaging - please wait a moment
  • electron-builder  version=24.6.4 os=21.6.0
  • packaging       platform=darwin arch=arm64 electron=26.4.0 appOutDir=dist/mac-arm64
  • signing         file=dist/mac-arm64/My Nextron identityName=Developer ID Application: Nick Payne (MY-TEAM-ID) identityHash=xxx provisioningProfile=none


Code signing guarantees the integrity and authenticity of an application and ensures it hasn't been tampered with. Notarization submits the application to Apple and verifies that it is not malicious via an automated scanning process. You cannot ship macOS applications without notarization past macOS 10.15, so let's get going.

The first thing we'll need to do is create an .env file which Electron Builder will use to load in the credentials needed to notarize each build of our app. Create electron-builder.env alongside your existing electron-builder.yml file, and make sure you add it to your .gitignore file so you don't accidentally commit it to source control:

echo "electron-builder.env" >> .gitignore
# electron-builder.env


You'll need to populate the APPLE_ID field with your Apple ID, and the APPLE_APP_SPECIFIC_PASSWORD field with an app-specific password you can generate from your Apple ID account. You'll also need XCode installed which provides the notarization tooling Electron Builder uses under the hood.

Next up, we need to add some configuration to the end of electron-builder.yml. Replace the publish: null key at the end of the file with the following code, and be sure to replace your-team-id with your own Apple Developer Team ID:

    teamId: your-team-id
  gatekeeperAssess: false
  hardenedRuntime: true
  entitlements: entitlements.plist
  entitlementsInherit: entitlements.plist

Create entitlements.plist alongside electron-builder.yml and add the following:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "">
<plist version="1.0">

This is the minimum level of entitlement needed to run our application. You'll come across lots of different recommendations on the Internet but I would advise you to ignore them until or unless you run into problems - there is a lot of outdated and occasionally misguided information out there. Now, run another npm run build and you should notice a telltale notarization successful log line sometime after your application has been signed successfully. With that, we're finally ready to actually distribute our application!

Distribution and automatic updates using GitHub

For a proof of concept it's perfectly valid to call it a day there - after all, you're a web developer who's just created a bona-fide desktop application. Get that CV updated, quick! That said, there are still a few last things which are worth doing - the most important of which is enabling application auto-updates. This is worth getting in as early as possible so users don't have to go looking for new versions of your application when they're available (after all, how would they even know?), allowing you to ship small, incremental updates as often as you like without inconveniencing your users.

As with most things in the Electron ecosystem, this process isn't particularly intuitive, but it's easy enough once you know what you're doing.

Installing electron-updater

We're going to use electron-updater as recommended by electron-builder, not Electron's built-in auto-update mechanism, simply because electron-updater supports auto-updating via GitHub Releases rather than requiring a dedicated Squirrel Server.

npm i electron-updater

No --save-dev here: this dependency needs to be bundled into the runtime application as it will be called from the main process on launch. Let's configure that now:

// ./main/background.ts

// after existing imports:
import { autoUpdater } from 'electron-updater'


// somewhere in the async initialisation function - I prefer just after app ready:
await app.whenReady()

Note that how and when you call checkForUpdatesAndNotify() is up to you. Here we're calling it once on startup. You could do it in response to a user action, or set up a timer to periodically check it, or both. The choice is yours. As the method name implies, it will check for updates and if any are available it will download them and notify the user that they'll be installed on restart.

Specifying a release repository

The code above is safe to add but won't do much yet because we haven't configured where to look for updates. The simplest thing to do is create a new GitHub repository - making sure it is publicly visible - and then updating electron-builder.yml accordingly. Add a new publish key to the top of your mac section:

    - provider: github
      owner: your-username
      repo: your-repository-name

Electron-builder now knows where to look, but it doesn't have permission to create releases in your repository. You need generate a new Personal Access Token and give it the full repo scope and add a new GH_TOKEN key to your electron-builder.env file containing your newly-generated token.

Releasing the application

We are finally ready to release our application! It'll look almost native, it'll come with a proper installer, it'll auto-update, and it'll be signed and notarized by Apple. Give yourself a pat on the back for making it this far. It's time to put the icing on the cake and release our application to the world. Open package.json and add a little helper script:

  "scripts": {
    "release": "nextron build --publish always"

Run npm run release, then sit back and glow as your application is built, signed, notarized, packaged, and uploaded to GitHub. This will take a while, but you should see something like this when it's done:

  * publishing      publisher=Github (owner: makeusabrew, project: stackman-releases, version: 0.0.6)
  * uploading       file=stackman-0.0.6-arm64.dmg.blockmap provider=github
  * uploading       file=stackman-0.0.6-arm64.dmg provider=github
  * creating GitHub release reason-release does not exist tag-v0.0.6 version-0.0.
    [===                 ] 14% 74.2s | stackman-0.0.6-arm64.dmg to github  • building block map  blockMapFile=dist/
    [===                 ] 16% 71.3s | stackman-0.0.6-arm64.dmg to github  • uploading provider=github
  * uploading provider=github
    [====================] 100% 0.0s | stackman-0.0.6-arm64.dmg to github
    [====================] 100% 0.0s | to github

This is the actual output for the release process for another app I'm building, Stackman, and you can find this release on GitHub.

All that remains is to simply head over to GitHub and publish the release you've just built. Electron builder will leave it in a 'draft' state, which allows you to publish the same version of a release repeatedly until you're ready to actually deploy it. As soon as you do, new users can download it manually via GitHub, and existing users will be prompted to update automatically the next time they start the application. You've done it. Well done!


You've built, signed, notarized, packaged and distributed a desktop application. You've even enabled automatic updates. That's a lot. But I still took huge chunks of the intended scope of this article out because it was already far too long, so check out part two and look out for part three of this series. Part two adds more functionality to our application along with a aesthetic improvements to make it feel more polished and native, while part three will add some release hardening and source code protection.

If you enjoyed this article, please consider sharing it on X or your platform of choice - it helps a lot. I’m all ears for any feedback, corrections or errors, so please if you have any. Thanks!