Introduction
Building an app with Electron and Next.js can be challenging, especially if you want to use React Server Components (RSCs). The typical Static Site Generation (SSG) approach often results in losing a significant amount of functionality and dynamic content.
Moreover, deploying the same codebase to both web and desktop can be complex. Using Vite in Electron and Next.js for SSR is an option, but it becomes cumbersome, especially with the advent of RSCs, using same components for both client and server is not possible without hacks.
In this blog, we’ll explore how to build an app with Next.js and Electron with Server Components support.
If you just want to see the code, you can find it here.
What are Server Components?
Server Components, or RSCs, are a new way to build React apps that offload the rendering of components to the server. This means you can build your app with the same components used for client-side rendering, but the server will render them for you. This can help improve performance and reduce the amount of code you need to write.
RSCs result in sending less JavaScript to the client, allowing the server to handle the heavy lifting of rendering the components. This is particularly useful for building websites that require a lot of dynamic content fetched from the server.
This is just a quick info, but you can read more about React server components in the official docs.
How it will look like?
Setting Up the Project
- Create a New Next.js Project:
npx create-next-app@latest nextjs-electron
- Install Dependencies:
npm install electron electron-builder tsup nodemon npm-run-all cross-env -D
npm install get-port-please @electron-toolkit/utils
- Update Next.js Config:
Update next.config.js
to output as ‘standalone’:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone", // This line
reactStrictMode: true,
};
export default nextConfig;
Outputting as ‘standalone’ results in a smaller package that we can use in Electron, with trimmed
node_modules
. This is also the best way when self-hosting the Next.js app.
- Update
package.json
:
Point to the transpiled file in the main
key:
{
"main": "build/main.js"
}
Add the following to ensure Electron knows what files to bundle:
"build": {
"asar": true,
"executableName": "NextJSElectron",
"appId": "com.saybackend.nextjs-electron",
"asarUnpack": [
"node_modules/next",
"node_modules/@img",
"node_modules/sharp",
"**\\*.{node,dll}"
],
"files": [
"build",
{
"from": ".next/standalone",
"to": "app",
"filter": [
"!**/.env",
"!**/package.json"
]
},
{
"from": ".next/static",
"to": "app/.next/static"
},
{
"from": "public",
"to": "app/public"
}
],
"win": {
"target": [
"nsis"
]
},
"linux": {
"target": [
"deb"
],
"category": "Development"
}
}
- Add Scripts to
package.json
:
"scripts": {
"next:dev": "next dev",
"next:build": "next build",
"next:start": "next start",
"next:lint": "next lint",
"format": "dprint fmt",
"postinstall": "electron-builder install-app-deps",
"electron:dist": "electron-builder --dir",
"electron:dist:deb": "electron-builder --linux deb",
"electron:build": "tsup",
"build": "run-s next:build electron:build",
"dist": "run-s next:build electron:dist",
"dev": "npm-run-all --parallel electron:dev next:dev",
"electron:build_watch": "tsup --watch",
"electron:dev": "npm-run-all --parallel electron:build_watch electron:watch",
"electron:watch": "cross-env NODE_ENV='development' nodemon"
}
Preferably, create a Makefile to run the commands, as it gets too complex to run them individually. You can find one Makefile here.
- Add
tsup.config.ts
:
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["./electron/main.ts", "./electron/preload.ts"],
splitting: false,
sourcemap: false,
clean: true,
cjsInterop: true,
skipNodeModulesBundle: true,
treeshake: true,
outDir: "build",
external: ["electron"],
format: ["cjs"],
bundle: true,
});
- Add
nodemon.json
:
{
"$schema": "https://json.schemastore.org/nodemon.json",
"exec": "electron .",
"watch": ["main"],
"ignore": ["build", "public/build"]
}
Electron Setup
Create a new folder called electron
, and add two files: main.ts
and preload.ts
.
main.ts
import { is } from "@electron-toolkit/utils";
import { app, BrowserWindow, ipcMain } from "electron";
import { getPort } from "get-port-please";
import { startServer } from "next/dist/server/lib/start-server";
import { join } from "path";
const createWindow = () => {
const mainWindow = new BrowserWindow({
width: 900,
height: 670,
webPreferences: {
preload: join(__dirname, "preload.js"),
nodeIntegration: true,
},
});
mainWindow.on("ready-to-show", () => mainWindow.show());
const loadURL = async () => {
if (is.dev) {
mainWindow.loadURL("http://localhost:3000");
} else {
try {
const port = await startNextJSServer();
console.log("Next.js server started on port:", port);
mainWindow.loadURL(`http://localhost:${port}`);
} catch (error) {
console.error("Error starting Next.js server:", error);
}
}
};
loadURL();
return mainWindow;
};
const startNextJSServer = async () => {
try {
const nextJSPort = await getPort({ portRange: [30_011, 50_000] });
const webDir = join(app.getAppPath(), "app");
await startServer({
dir: webDir,
isDev: false,
hostname: "localhost",
port: nextJSPort,
customServer: true,
allowRetry: false,
keepAliveTimeout: 5000,
minimalMode: true,
});
return nextJSPort;
} catch (error) {
console.error("Error starting Next.js server:", error);
throw error;
}
};
app.whenReady().then(() => {
createWindow();
ipcMain.on("ping", () => console.log("pong"));
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
preload.ts
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("electron", {
ipcRenderer: {
send: (channel: string, data: any) => ipcRenderer.send(channel, data),
on: (channel: string, listener: (event: any, ...args: any[]) => void) =>
ipcRenderer.on(channel, listener),
},
});
Explanation
Since Next.js 13, they do not provide a way to use a custom server like through Express.js to run the server for Next.js, as it requires RSC support. We can use the server already built by the Next.js team and put it to use here.
The port situation is different from other ways to do an Electron app, as they either use JS files or index.html
. Here, we need to run the server for SSR and RSC experience. We are using get-port-please
to get a port for the Next.js server to run on.
const nextJSPort = await getPort({ portRange: [30011, 50000] }); // It's better to use a range, as it's less likely to be used by other apps.
const webDir = join(app.getAppPath(), "app"); // This is where the Next.js build is stored.
await startServer({
dir: webDir,
isDev: false,
hostname: "localhost",
port: nextJSPort,
customServer: true,
allowRetry: false,
keepAliveTimeout: 5000,
minimalMode: true,
});
// These are just default values, you can change them as per your needs.
const loadURL = async () => {
if (is.dev) {
mainWindow.loadURL("http://localhost:3000");
} else {
try {
const port = await startNextJSServer();
mainWindow.loadURL(`http://localhost:${port}`);
} catch (error) {
console.error("Error starting Next.js server:", error);
}
}
};
Here we are checking if the app is in development, or production mode, and load the URL accordingly. No need to run everything in development, since even when we are running the Next.js app on separate process, it can still do everything it needs during dev.
Running the app
Now, you can run the app by running the following command:
npm run dev
or
make dev
This will start the Next.js, Tsup in watch mode for the electron, and nodemon to watch the transpiled files.
Now you will see the window pop up with the Next.js app running in it. You can now try editing any Next.js file and it will automatically reload the app through HMR. Pretty easy right?
Similarly you can also edit the Electron files and it will reload the app, the window will close and open again with the changes.
As you can see, we are getting a perfectly running server component, a client component, and a way to call electron for native functionalities.
Sending commands to Electron from Next.js
You can send commands to the electron from the Next.js app by using the electron
object that we exposed in the preload.ts file.
import { useEffect } from "react";
export default function Home() {
useEffect(() => {
electron.ipcRenderer.send("ping");
}, []);
return (
<div>
<h1>Hello, World!</h1>
</div>
);
}
And in the main.ts file, you can listen to the ping command like this:
ipcMain.on("ping", () => console.log("pong"));
And that’s it! You have successfully built an app with Next.js and Electron with Server Components support.
Click on the Ping Electron button, and you will see the pong message in the console.
Try creating a server component, you will see it’s working as expected, and the server is doing the heavy lifting of pre rendering the components.
Pakaging the app
To package the app, you can run the following command:
npm run electron:dist
or
make electron_dist
Current it’s only packaging it as the dir, you can change it to deb, or dmg, or any other format you want.
GitHub Repository
You can find the complete code for this blog in the GitHub repository here.
I’m looking for a job, if you are looking for a senior backend developer, please consider me, I’m available on my email [email protected]