Angular + Electron: a typed preload bridge beats nodeIntegration
nodeIntegration in the renderer is a classic footgun. Keep Node in main/preload and give Angular a narrow, typed bridge via contextBridge.
Angular in Chromium is one thing. Angular in Electron is the same UI plus native APIs: files, dialogs, tray, auto-update. You can bridge renderer and main in two ways: turn on nodeIntegration and import Electron inside a component, or keep Node out of the Angular world and expose a narrow preload bridge. Electron's official guidance is the second option. Guides: Context Isolation and Inter-Process Communication.
After Angular Signals and effect(), the coherent ecosystem follow-up is desktop: the same Angular app inside an Electron shell, not a hop to Next.js.
The problem: nodeIntegration: true in the renderer
By default Electron keeps the renderer constrained: nodeIntegration is off (since Electron 5), contextIsolation is on (since Electron 12), and renderer sandboxing is on (since Electron 20). Security checklist: Security. Preference details: WebPreferences.
Turning Node on in the renderer feels convenient because you can require('fs') from a component. The cost: XSS or a malicious UI script gets Node on the user's machine. With nodeIntegration: true, the sandbox also drops. That is not a shortcut for a faster desktop prototype. It turns a web bug into RCE.
const win = new BrowserWindow({
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
});Angular does not need Node in the renderer. It needs a contract: "open a dialog", "save preferences", "return the app version". Main handles the rest.
The bridge: preload + contextBridge + a typed Angular service
Electron's documented pattern: main registers a handler, preload exposes one method per channel, the renderer calls window.electronAPI…. Do not expose raw ipcRenderer, or unfiltered send / invoke. The docs say that lets any page fire arbitrary IPC channels.
1. Main: ipcMain.handle
import { app, BrowserWindow, dialog, ipcMain } from "electron";
import path from "node:path";
// returns string | null
async function handleOpenFile() {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ["openFile"],
});
if (canceled || filePaths.length === 0) return null;
return filePaths[0] ?? null;
}
function createWindow(): void {
const win = new BrowserWindow({
webPreferences: {
// defaults: contextIsolation true, nodeIntegration false, sandbox true
preload: path.join(__dirname, "preload.js"),
},
});
// dev: loadURL(ng serve); prod: loadFile(dist/…)
void win.loadURL("http://localhost:4200");
}
app.whenReady().then(() => {
ipcMain.handle("dialog:openFile", handleOpenFile);
createWindow();
});The dialog: prefix is only a readability namespace. The channel is a string you define.
2. Preload: one method per channel
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("electronAPI", {
// returns Promise of string | null
openFile: () => ipcRenderer.invoke("dialog:openFile"),
});A sandboxed preload (default since Electron 20) loads as restricted CommonJS: usually one bundled .js file, not live ESM import at runtime. If window.electronAPI is undefined after boot, check the preload path and the preload bundle format before debugging Angular.
3. Types for window
export interface ElectronAPI {
openFile: () => Promise / string | null /;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
export {};Same pattern Electron shows under Context Isolation: Usage with TypeScript.
4. Angular service + thin component
import { Injectable } from "@angular/core";
@Injectable({ providedIn: "root" })
export class DesktopBridgeService {
openFile(): Promise / string | null / {
if (typeof window === "undefined" || !window.electronAPI) {
// browser / ng serve without Electron
return Promise.resolve(null);
}
return window.electronAPI.openFile();
}
}import { Component, signal } from "@angular/core";
import { DesktopBridgeService } from "./desktop-bridge.service";
@Component({
selector: "app-desktop-status",
templateUrl: "./AppDesktopStatusComponent.html",
})
export class AppDesktopStatusComponent {
readonly lastPath = signal(null as string | null);
constructor(private readonly desktop: DesktopBridgeService) {}
async pickFile(): Promise / void / {
const path = await this.desktop.openFile();
this.lastPath.set(path);
}
}// button type="button" with (click)="pickFile()" label: Open file
// @if (lastPath(); as path) { show: Selected: {{ path }} }The component knows nothing about Electron. It knows a service. In the browser the bridge is absent and you get null instead of a crash under ng serve.
For two-way IPC prefer ipcRenderer.invoke + ipcMain.handle. sendSync blocks the renderer until the reply arrives. The IPC docs discourage it for performance reasons.
Pitfalls and trade-offs
Raw IPC over the bridge. exposeInMainWorld({ send: ipcRenderer.send }) or shipping the whole ipcRenderer is an anti-pattern. On newer Electron versions, sending full ipcRenderer across contextBridge yields an empty object anyway; even when it worked, the renderer could hit any channel.
Main → renderer listeners. Do not pass a callback straight into ipcRenderer.on. The first argument is an event with sender and IPC access. Wrap it: (_event, value) => callback(value). Checklist item 20: Do not expose Electron APIs to untrusted web content.
Objects across the bridge. contextBridge copies values; custom prototypes and symbols do not travel. If preload accepts objects from the renderer, validate own properties (Object.hasOwn) or stick to primitives. A narrow contract beats "dump any and hope".
Dev vs prod paths. Dev loads ng serve; prod uses loadFile or a custom protocol against the built Angular app. Preload must point at an absolute path to the built file, not the TypeScript source.
When nodeIntegration "must" stay. Almost never in UI code. If a native Node module is required, keep it in main (or a dedicated utility process) and call it over IPC. Exceptions exist, but they are a conscious security exception, not the scaffolding default.
Verdict
Angular in Electron does not need Node in the renderer. It needs a contract. Keep contextIsolation and the sandbox on, expose one method per channel from preload, type window.electronAPI, and wrap it in an Angular service. nodeIntegration: true looks like a shortcut. In practice it is a shortcut to the wrong threat model.
Once the bridge works, the natural next piece in this lane is packaging (electron-builder / Forge) and Angular asset paths in the production bundle. Or Ionic/Capacitor if you aim at mobile instead of desktop.