DEV Community

Cover image for The ultimate Electron app with Next.js and React Server Components
Kirill Konshin
Kirill Konshin

Posted on • Edited on

The ultimate Electron app with Next.js and React Server Components

With the emergence of React Server Components and Server Actions writing Web apps became easier than ever. The simplicity when developer has all server APIs right inside the Web app, natively, with types and full support from Next.js framework for example (and other RSC frameworks too, of course) is astonishing.

At the same time, Electron is a de-facto standard for modern desktop apps written using web technologies, especially when application must have filesystem and other system API access, while being written in JS (Tauri receives an honorable mention here if you know Rust or if you only need a simple WebView2 shell).

I asked myself, why not to combine best of both worlds, and run usual Next.js application right inside the Electron and enjoy all benefits that comes with React Server Components?

Demo

I have explored all options available and haven’t found a suitable one, so I wrote a small lib next-electron-rsc that can bridge the gap between Next.js and Electron without running a server or opening any ports.

All you need to use the lib is to add following to your main.js in Electron:

import { app, protocol } from 'electron';
import { createHandler } from 'next-electron-rsc';

const appPath = app.getAppPath();
const isDev = process.env.NODE_ENV === 'development';

const { createInterceptor } = createHandler({
    standaloneDir: path.join(appPath, '.next', 'standalone'),
    localhostUrl: 'http://localhost:3000', // must match Next.js dev server
    protocol,
});

if (!isDev) createInterceptor();
Enter fullscreen mode Exit fullscreen mode

And configure your Next.js build in next.config.js:

module.exports = {
  output: 'standalone',
  experimental: {
    outputFileTracingIncludes: {
      '*': [
          'public/**/*',
          '.next/static/**/*',
      ],
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Here’s the repository: https://github.com/kirill-konshin/next-electron-rsc and the demo with all files.

And here’s my journey to create this lib.

Motivation to use React Server Components in Electron

Electron’s native way of providing access to system APIs is via IPC or, god forbid, Electron Remote (which was considered even harmful). Both were always a bit cumbersome. Don’t get me wrong, you can get the job done: this and this typed IPC interfaces were the best I found. But with IPC in large apps you’ll end up designing some handshake protocol for simple request-response interaction, handling of errors and loading states and so on, so in real enterprise grade applications it will quickly become too heavy. Not even close to elegance of RSC.

Important benefit of React Server Components in traditional client-server web development has same nature: absence of dedicated RESTful API or GraphQL API (if the only API consumer is the website itself). So developer does not need to design these APIs, maintain them, and app can just and just talk to backend as if it’s just another async function.

With RSC application all logic can be colocated in the Web app, so Electron itself becomes a very thin layer, that just opens a window.

Here’s an example, we use Electron’s safe storage and read from/write to file system right in the React Component:

import { safeStorage } from 'electron';
import Preview from './text';
import fs from 'fs/promises';

async function Page({page}) {
  const secretText = await safeStorage.decryptString(await fs.readFile('path-to-file'));

  async function save(newText) {
          fs.writeFile('path-to-file', await safeStorage.encryptString(newText));
  }

  return <Preview secretText={secretText} save={save} />;
}
Enter fullscreen mode Exit fullscreen mode

Such colocation allows much more rapid development and much less maintenance of the protocol between Web and Electron apps. And of course you can use Electron APIs directly from server components, as it’s the same Node.js process, thus removing the necessity to use IPC or Remote, or any sort of client-server API protocol like REST or GQL.

Basically, this magically removes the boundary between Electron’s Renderer and Main processes, while still keeping everything secure. Besides you can shift execution of heavy tasks from browser to Node.js, which is more flexible how you distribute the load. The only problem is… you need to run an RSC server in Electron. Or do you?

Requirements

I had a few and very strict requirements that I wanted to achieve:

  1. No open ports! Safety first.
  2. Complete support Next.js: React Server Components, API Routes (App router) and Server Side Rendering, Static Site Rendering and Route Handlers (Pages router), you name it, with strict adherence established patterns
  3. Minimal, easy to use, based on standards, basically an enterprise-grade, production ready stack for commercial use, mature and well known set of technologies
  4. Performance

After some research I found an obvious choice called Nextron. Unfortunately, seems like it does not utilize the full power of Next.js, and does not support SSR (ticket remained open in Oct 2024). On the other hand there are articles like this or this, both very close, except for usage of server with an open port. Unfortunately I only found it after I came up with the approach I’m about to present, but the article validated it. Luckily I found it before writing this post, so I can give kudos to the author here.

So I started exploring on my own. Turned out, the approach is pretty simple. And all the tools are already available, I only needed to wire them together in some unorthodox way.

Next.js

First step would be to build Next.js app as a standalone. This will create an optimized build which contains all modules and files that can possibly be required in runtime, and removes everything that’s unnecessary.

module.exports = {
  output: 'standalone',
  experimental: {
    outputFileTracingIncludes: {
      '*': [
          'public/**/*',
          '.next/static/**/*',
      ],
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Aaand, this is it for Next.js.

outputFileTracingIncludes is needed so that optional public and .next/static folders will be copied to standalone build. Next.js assumes you should publish this to CDN, but in this case everything is local.

Next step is a little trickier.

Electron

Now I need to let Electron know that I have Next.js.

One possible solution is Electron’s Custom Protocol or Schema. Or a Protocol Intercept. I chose the latter as I’m perfectly fine to pretend to load web from http://localhost (emphasis on pretend as there should be no real server with an open port).

Besides, this also ensures relaxed policy of one “popular video service”, that forbids embedding on pages served via custom protocols 😅.

Please note that I purposely excluded a lot of unnecessary code to focus on what matters to show the concept.

To implement the intercept I added following:

const localhostUrl = 'http://localhost:3000';

function createInterceptor() {
    protocol.interceptStreamProtocol('http', async (request, callback) => {
        if (!request.url.startsWith(localhostUrl)) return;
        try {
            const response = await handleRequest(request);
            callback(response);
        } catch (e) {
            callback(e);
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

This interceptor serves static files and forwards requests to Next.js.

Honorable mention here goes to awesome Electron Serve, which implements a custom schema for serving static files.

Bridging Electron and Next.js

Next step would be to create a file to provide some convenience to use the non-existing port-less “server”:

import type { ProtocolRequest, ProtocolResponse } from 'electron';

import { IncomingMessage } from 'node:http';
import { Socket } from 'node:net';

function createRequest({ socket, origReq }: { socket: Socket; origReq: ProtocolRequest }): IncomingMessage {
    const req = new IncomingMessage(socket);

    req.url = origReq.url;
    req.method = origReq.method;
    req.headers = origReq.headers;

    origReq.uploadData?.forEach((item) => {
        req.push(item.bytes);
    });

    req.push(null);

    return req;
}
Enter fullscreen mode Exit fullscreen mode

createRequest uses a Socket to create an instance of Node.js IncomingMessage, then it transfers the information from Electron’s ProtocolRequest into the IncomingMessage, including the body of POST|PUT requests.

import { ServerResponse, IncomingMessage } from 'node:http';
import { PassThrough } from 'node:stream';
import type { Protocol, ProtocolRequest, ProtocolResponse } from 'electron';

class ReadableServerResponse extends ServerResponse {
    private passThrough = new PassThrough();
    private promiseResolvers = Promise.withResolvers<ProtocolResponse>();

    constructor(req: IncomingMessage) {
        super(req);
        this.write = this.passThrough.write.bind(this.passThrough);
        this.end = this.passThrough.end.bind(this.passThrough);
        this.passThrough.on('drain', () => this.emit('drain'));
    }

    writeHead(statusCode: number, ...args: any): this {
        super.writeHead(statusCode, ...args);

        this.promiseResolvers.resolve({
            statusCode: this.statusCode,
            mimeType: this.getHeader('Content-Type') as any,
            headers: this.getHeaders() as any,
            data: this.passThrough as any,
        });

        return this;
    }

    async createProtocolResponse() {
        return this.promiseResolvers.promise;
    }
}
Enter fullscreen mode Exit fullscreen mode

ReadableServerResponse is basically just a regular Node.js ServerResponse from which I can read the body once Next.js finishes the processing. createProtocolResponse converts the ReadableServerResponse into Electron’s ProtocolResponse.

createProtocolResponse method returns a Promise which waits for the body and resolves into converted ReadableServerResponse as ProtocolResponse.

Next step is finally the “server” itself.

No server, no ports

import type { ProtocolRequest, ProtocolResponse } from 'electron';

export function createHandler({
    standaloneDir,
    localhostUrl = 'http://localhost:3000',
    protocol,
    debug = false,
}) {
    const next = require(resolve.sync('next', { basedir: standaloneDir }));

    const app = next({
        dev: false,
        dir: standaloneDir,
    }) as NextNodeServer;

    const handler = app.getRequestHandler();

    const socket = new Socket();

    async function handleRequest(origReq: ProtocolRequest): Promise<ProtocolResponse> {
        try {
            const req = createRequest({ socket, origReq });
            const res = new ReadableServerResponse(req);
            const url = parse(req.url, true);

            handler(req, res, url);

            return await res.createProtocolResponse();
        } catch (e) {
            return e;
        }
    }

    function createInterceptor() { /* ... */ }

    return { createInterceptor };
}
Enter fullscreen mode Exit fullscreen mode

I use the NextServer from Next.js app’s standalone build to create a handler, a regular Express-like route handler which takes Request and Response as arguments.

Key function here is handleRequest.

It provides a dummy Socket to createRequest to create a dummy IncomingMessage, creates a dummy ReadableServerResponse. I feed both request and response to Next.js’s handler, so Next.js can work its magic, not knowing that there’s no actual server, just dummy mocks. Once handler finishes its job the ProtocolResponse is ready for Electron to send to browser. And this is it.

Note that I don’t actually start the Next.js or any other server anywhere, so Requirement #1 is achieved, no ports are open. You can take a look at Next.js documentation to learn more about regular way of setting up a handler with the server. And since I use regular Next.js way, Requirement #2 is achieved.

And since this whole approach works fine on highly loaded servers, and with Electron there’s just one user at any time, the performance Requirement #4 is achieved as well.

Bundling and publishing

I suggest to use Electron Builder to bundle the Electron app. Just add some configuration to electron-builder.yml:

includeSubNodeModules: true

files:
  - build
  - from: '.next/standalone/demo/'
    to: '.next/standalone/demo/'
Enter fullscreen mode Exit fullscreen mode

For convenience, you can add following scripts to package.json:

{
  "scripts": {
    "build": "yarn build:next && yarn build:electron",
    "build:next": "next build",
    "build:electron": "electron-builder --config electron-builder.yml",
    "start:next": "next dev",
    "start:electron": "electron ."
  }
}
Enter fullscreen mode Exit fullscreen mode

For separation of concerns I recommend to keep Next.js sources in src of and Electron soures in and src-electron, this ensures Next.js does not try to compile Electron.

Conclusion

Requirement #3 is achieved in full glory since it’s just one file, and it only uses standard APIs.

I was amazed when it actually worked… I was quite skeptical that it would be this simple and yet so elegant.

Now I can enjoy full access to file & operating systems directly from Next.js Server Components or Route Handlers, with all the benefits of Next.js ecosystem, established patterns, and while using Electron to deliver the complete app experience to users, since the app can be bundled and published.

P.S. I have done my due diligence and I have not found any articles that cover usage of Next.js with mocked requests and responses, especially in conjunction with Electron. Shame on me if otherwise, I must have forgotten how to Google 🤓… But even if I missed something, this article should help to explain why this approach is good.

P.P.S. MSW is a bit overkill and is used for different purposes, like other HTTP mocking libraries.

P.P.P.S. Few shady things in the code are using buffers to read response and synchronous reading static files, both can be improved with streaming, but for simplicity it’s good enough.

Top comments (0)