If you ever need to wrap a React app in Electron, you'll surely find there's no shortage of tutorials that explain how to set up your project in your local development environment. But what happens when you're ready to package up that app in distributable formats so users can install it on their systems?
I recently found myself needing to do this on a project and found that I had to do a bit of experimentation before setting on how to best approach generating, developing, and packaging an Electron app with code scaffolded from Create React App under the hood.
In this post, I'll share with you what steps I followed to make this happen and how I reconciled the code generated by the CRA and Electron Forge CLIs to run no-fuss builds with a single command.
TL;DR: You can find the complete code from this demo here.
Set Up the React App with Electron
The approach we'll take to set up our app will involve a few steps:
- Scaffold a new React app using the CRA CLI tool
- Add a file to start up an Electron app with the React app running inside of it
- Import the project into the Electron Forge workflow so that it can easily be packaged up for distribution
We'll take care of the first two steps in this section and then configure our project with Electron Forge in the next section. Let's begin by creating the new React app:
npx create-react-app cra-electron-forge-demo --use-npm
I'll use npm in this tutorial so I've passed the --use-npm
flag above, but you can use Yarn if you prefer too. Now we'll change into our new project directory:
cd cra-electron-forge-demo
Our next step will be to install Electron in our project as a development dependency:
npm i -D electron@9.1.1
And we'll also install a package that will make it easy for us to detect if we're running in a development or production environment in our Electron app:
npm i electron-is-dev@1.2.0
Moving onto the second step, we're going to add a file to the public
directory called electron.js
to contain all of our Electron-related code.
touch public/electron.js
Note that you will often see this file called main.js
in tutorials, but I think that calling it electron.js
here disambiguates what it's purpose is!
Inside of electron.js
, we'll add the following code:
const path = require("path");
const { app, BrowserWindow } = require("electron");
const isDev = require("electron-is-dev");
function createWindow() {
// Create the browser window.
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: true
}
});
// and load the index.html of the app.
// win.loadFile("index.html");
win.loadURL(
isDev
? "http://localhost:3000"
: `file://${path.join(__dirname, "../build/index.html")}`
);
// Open the DevTools.
if (isDev) {
win.webContents.openDevTools({ mode: "detach" });
}
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(createWindow);
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("activate", () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.
The code above has been adapted from a starter example in the Electron docs, but with a few notable changes:
- We use the
electron-is-dev
package to conditionally point Electron to either our local development server or a bundled production version of our React app (and we use Node'spath
module to help with this). - We conditionally open the Chrome dev tools depending on the environment (because we only want them to open automatically in development).
- Lastly—and this a matter of personal preference—we detach the dev tools from the app's main window when it launches. If you would prefer to keep the dev tools attached to the main window, then you can instead leave the object argument out when calling the
win.webContents.openDevTools
method.
With this file in place, we'll need to declare it as the main entry point for the Electron app in our package.json
file:
{
"name": "cra-electron-forge-demo",
"version": "0.1.0",
"main": "public/electron.js", // NEW!
// ...
}
Now we need to think about how we're going to start our app up. In the development environment, we'll want to start the React app up first and only launch Electron once http://localhost:3000 is available. Ideally, we'd also prevent the default CRA behavior that opens a browser tab with our app running in it. (We don't have to worry about any this in production because Electron will just load the static index.html
and bundled JS files.)
To launch our development server and Electron together, we'll need to install two more packages:
npm i -D concurrently@5.2.0 wait-on@5.1.0
Concurrently will allow us to run multiple commands in one npm script and wait-on will require Electron to wait for port 3000 to be available before the app launches. We'll use these packages to add dev
and electron
scripts to our package.json
file:
{
// ...
"scripts": {
"dev": "concurrently -k \"BROWSER=none npm start\" \"npm:electron\"",
"electron": "wait-on tcp:3000 && electron .",
// ...
}
// ...
}
Passing the BROWSER=none
option before npm start
will prevent a regular browser tab from launching once our React app starts up. If we run npm run dev
now, then we'll be able to see our React app running with Electron instead of in a browser window:
Configuring Electron Forge
If all we wanted to do was wrap a React app in Electron and experiment with it in our development environment, then we could call it a day here. But odds are that you'll likely want other people be to able to use your app like any other desktop app on their computers too!
There are different options available for packaging up Electron apps for different platforms, but the best all-in-one solution I've tried is Electron Forge. We can import our existing Electron app into the Electron Forge workflow by running this command in our project now:
npx @electron-forge/cli import
When the importer script runs (and it may take a couple of minutes...), Electron Forge will install some additional dependencies and make some changes to our package.json
file. If we take a look at the scripts
in the package.json
file, we'll see that it has modified our existing start
script to this:
{
// ...
"scripts": {
// ...
"start": "electron-forge start",
// ...
}
// ...
}
We'll need to change this script back to the way it was before:
{
// ...
"scripts": {
// ...
"start": "react-scripts start",
// ...
}
// ...
}
We'll also make a small adjustment to the new package
and make
scripts to also build our React app beforehand:
{
// ...
"scripts": {
// ...
"package": "react-scripts build && electron-forge package",
"make": "react-scripts build && electron-forge make"
}
// ...
}
Lastly, we'll modify our own electron
script to use electron-forge
to start up the Electron app instead:
{
// ...
"scripts": {
// ...
"electron": "wait-on tcp:3000 && electron-forge start",
// ...
}
// ...
}
Over in public/electron.js
now, we can use the electron-squirrel-startup
package installed Electron Forge to handle creating and removing shortcuts on Windows (should we wish to package our app for that platform too):
const path = require("path");
const { app, BrowserWindow } = require("electron");
const isDev = require("electron-is-dev");
// Handle creating/removing shortcuts on Windows when installing/uninstalling
if (require("electron-squirrel-startup")) {
app.quit();
} // NEW!
// ...
With our new code in place, we should be able to run npm run dev
again and see the Electron app start just as it did before.
Add Support for React Developer Tools
If we're building a React app, there's a good chance that we're going to want to have access to the React Developer Tools extension at some point. We'll need to add support the React Developer Tools manually to our app by installing this package:
npm i -D electron-devtools-installer
Next, we'll add some new code at the top of public/electron.js
to conditionally require this package when our development environment is detected:
const path = require("path");
const { app, BrowserWindow } = require("electron");
const isDev = require("electron-is-dev");
// Conditionally include the dev tools installer to load React Dev Tools
let installExtension, REACT_DEVELOPER_TOOLS; // NEW!
if (isDev) {
const devTools = require("electron-devtools-installer");
installExtension = devTools.default;
REACT_DEVELOPER_TOOLS = devTools.REACT_DEVELOPER_TOOLS;
} // NEW!
// Handle creating/removing shortcuts on Windows when installing/uninstalling
if (require("electron-squirrel-startup")) {
app.quit();
} // NEW!
// ...
Lastly, we'll need to call the installExtension
function when the app is ready, so we must update the existing app.whenReady().then(createWindow);
lines as follows:
// ...
app.whenReady().then(() => {
createWindow();
if (isDev) {
installExtension(REACT_DEVELOPER_TOOLS)
.then(name => console.log(`Added Extension: ${name}`))
.catch(error => console.log(`An error occurred: , ${error}`));
}
}); // UPDATED!
// ...
If we relaunch the app now, we should be able to see that the Components and Profiler tabs are available in the dev tools window:
Building for Distribution
We're finally ready to generate a platform-specific distributable so other people can install our app. In the following example, we'll build the Mac version of the app.
First, we'll need to set a homepage
property in the package.json
file to help CRA correctly infer the root path to use in the generated HTML file:
{
"name": "cra-electron-forge-demo",
"version": "0.1.0",
"main": "public/electron.js",
"homepage": "./",
// ...
}
As a nice touch, we can also create a custom app icon to appear in the user's dock using the electron-icon-maker
package. To do this, we'll need to supply it an absolute path to a PNG file that's at least 1024px by 1024px. We'll run this script from the root directory of our project to generate the icon files:
npx electron-icon-maker --input=/absolute/path/to/cra-electron-forge-demo/src/app-icon.png --output=src
Next, we can add the correct icon file and customize the name our app (as it will appear in the top menu or when hovering over the dock icon) under the config
key in our package.json
file:
{
// ...
"config": {
"forge": {
"packagerConfig": {
"icon": "src/icons/mac/icon.icns",
"name": "React + Electron App"
},
// ...
}
}
}
Note that if you wish to change the name that appears at the top of the window, you'll need to update that in the title
element in the public/index.html
file before building the app:
Now we can run a single command to package up our app for distribution:
npm run make
The packaged app can now be found in a new out
directory in the root of our project. You can now drag this app in your Applications
folder and run it like any other app on your system.
Finally, if you plan on version controlling this project with Git, then be sure to add the out
directory to the .gitignore
file before making your next commit:
# ...
# production
/build
/out # NEW!
# ...
Summary
In this tutorial, we used Create React App to scaffold a React app, which we then wrapped in Electron. We imported the app into an Electron Forge workflow using the provided CLI tool, made some adjustments for it to play nice with a CRA app, and then generated a distribution of the app for Mac.
You can find the complete code for this tutorial here.
I hope you've found the code in this tutorial helpful! Thanks for coding along and please leave any questions or comments below.
Top comments (32)
Thank you! Excellent guide!
A note for those using react-router 6, you'll need to replace BrowserRouter with HashRouter or you'll get a white screen when building for production.
omg you saved my life bro thank you <3
Thanks for the tip Alfonso
Well written article helped me with the setup however I still had to do:
npm i -D @electron-forge/maker-squirrel
npm i -D @electron-forge/maker-zip
npm i -D @electron-forge/maker-deb
npm i -D @electron-forge/maker-rpm
Then I ran into other problems, like the file folder was to long and such. I had to fix it by adding
Another thing with this setup although it works the electron app is 300mb .... this is a lot there are probably ways on making it smaller, this could be part of this article or a new one maybe ?
Hey Mandi,
I just happened to be working on moving our existing React dashboard from electron-builder to electron-forge, when I stumbled upon your perfectly timed article. I'm running into some issues with setting up the electron forge building process.
We've used create-react-app to set up the application initially, and are now using a folder structure where the src folder contains all our sources. There's also a public folder (from CRA), and we have some extra folders for unit tests and a bunch of files to configure things like GitLab CI, Babel, GraphQL, etc. CRA builds the application for us to the build folder, but when using electron forge to package / make the app, it will copy literally everything in the root folder (including tests, node modules, and our project config files) into the packaged app.
I've been looking for hours on Google to find out how I could specify a folder (e.g. the build folder) for Forge to use, but it seems like that's not possible at all. For the time being, I've just put every non-important folder to the ignore key of the packager config, but that's not a very solid solution.
Hoping you know a bit more about this than I do, and that you might be able to help me out!
Edit: I also just found out that CRA copies the public files into the build folder, but Electron Forge will still use the original files it copied over in the public folder, so you essentially have those files packaged twice in the final executable.
Hi Ririshi! This is a bit outside the scope of what I've done with Electron Forge, but if I discover a solution as I continue to experiment with it, then I'll post it here.
Have u found nothing? :(
Any luck resolving this?
Install electron-forge globally and run
electron-forge init electron-forge-react --template=react
electron-forge v6 does no longer have a react template
Please do not install npm packages globally... safety first. Npx is there to help 😉
I'm using the latest version (v6.0.0) of the
wait-on
npm package and my electron app didn't ever seem to pop up properly after my react app started up.To investigate, I started up my react app on port 3000 and then
cd
'ed into mynode_modules/wait-on/bin
directory and ran./wait-on tcp:3000 -v
. I was getting the following error.My react app was 100% running on localhost:3000, as I could access it in my browser.
I stopped that running
wait-on
process and re-ranwait-on
, passing my "localhost" IP into the wait-on command like so:./wait-on tcp:127.0.0.1:3000 -v
Finally then got
TCP connection successful to host:127.0.0.1 port:3000
So i modified the
electron
script in mypackage.json
as follows:Great debugging here. Saved me.
Thanks Brittany,
Was getting the exact same issue trying to start electron.
Appreciate the post :)
Hi
thanks for this it's really useful. I can get the dev version running no problem, but when I try and package with
npm run make
I get the following error:I tried Googling, but couldn't find any similar problem. Any ideas?
Thanks
I updated npm and all packages and it now runs OK
you can't use Browser= none without using cross env
It gets an error: npm ERR! app@0.1.0 dev:
concurrently -k "BROWSER=none npm start" "npm:electron"
npm install --save cross-env
"start": "cross-env BROWSER=none react-scripts start",
Nice blog. I have one question though: hot reloading seems to work for the ReactJS application but not for Electron; I mean, once any change is done in the electron.js file then the changes do not take effect until stop and run again the "dev" command. Any suggestion on how to add Hot reload also to the Electron code?
This is excellent! Many thanks!
One comment, on windows cross-env needs to be added for BROWSER=none to work correctly.
likewise! I went with
"dev": "concurrently -k \"cross-env BROWSER=none npm start\" \"npm:electron\""
or you can simply add it to your projects .env file. it works the same
I'm also on Windows and I use a .env file.
Very helpful thank you!
I ran into one issue I wanted to document in case anyone else ran into it too. When building I was getting a blank screen and it took me awhile to figure out why. In electron.js I changed:
win.loadURL(
isDev
? "localhost:3000"
:
file://${path.join(__dirname, "../build/index.html")}
);
to
win.loadURL(
isDev
? "localhost:3000"
:
file://${path.join(__dirname, "/../build/index.html")}
);
and it was able to launch my app correctly!
Thanks,
what is
package
what ismake
for forge?why do we have to run start with
electron-forge start
?Thank you! This article helps me a lot!
While importing electron-forge with
npx @electron-forge/cli import
I got an error:
could not determine executable to run
I'not sure if this is related to npm version, my version is 8.3.1
finally I solve this with the command below
This solution comes from electronforge.io/import-existing-p...
Hi Mandi,
Thanks to your tutorial my app was working fine but since i've updated macOS and Node it's not...
Nothing happen anymore when electron is supposed to start.
Do you know where it could come from ?
It would come from wait-on which is always refused when starting a connection even when react is started
Good post I really need to give this a go before I try this with Flutter and React Native.
Thank you for this guide! It is incredibly helpful.