paint-brush
How to Integrate Next.js with Electron Using React Server Componentsby@DiS
148 reads

How to Integrate Next.js with Electron Using React Server Components

by Kirill KonshinNovember 8th, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Next-electron-rsc is a lib that can bridge the gap between Next.js and Electron without running a server or opening any ports. It can be used to run normal Next.JS application with React Server Components right inside the Electron.
featured image - How to Integrate Next.js with Electron Using React Server Components
Kirill Konshin HackerNoon profile picture

With the emergence of React Server Components and Server Actions writing Web apps has become easier than ever. The simplicity when a 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 the 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 the 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();


Configure your Next.js build in next.config.js:

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

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 the elegance of RSC.


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


With the 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 the 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} />;
}

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.


This magically removes the boundary between Electron’s Renderer and Main processes, while still keeping everything secure. Besides you can shift the execution of heavy tasks from the browser to Node.js, which is more flexible in 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 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 to 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 the usage of a 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

The first step would be to build the Next.js app as a standalone. This will create an optimized build that 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/**/*',
      ],
    },
  },
};


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.


The 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 pretending to load the web from http://localhost (emphasis on pretend as there should be no real server with an open port).


Besides, this also ensures a 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 the 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);
        }
    });
}

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

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

Bridging Electron and Next.js

The next step would be to create a file to provide some convenience to using 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;
}


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;
    }
}


ReadableServerResponse is 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 a converted ReadableServerResponse as ProtocolResponse.


The 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 };
}


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.


The 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 the 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 using Electron Builder to bundle the Electron app. Just add some configuration to electron-builder.yml:

files:
  - '**/*'
  - '!.next'
  - '!next.config.js'
  - '!src*'
  - '!tsconfig*'

extraResources:
  - from: .next/standalone
    to: app/.next/standalone


For convenience, you can add the 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 ."
  }
}


For separation of concerns I recommend keeping Next.js sources in src of and Electron sources 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 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 the Next.js ecosystem, and 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. A Few shady things in the code are using buffers to read responses and synchronous reading static files, both can be improved with streaming, but for simplicity, it’s good enough.