Back to list
October 5, 2026•10 min read

Angular + Electron: the blank window after packaging is a path bug, not Angular

Blank window and 404s for main.js after electron-forge make? It's not Angular, it's three paths drifting apart: dist/browser, the base href, and loading from the file protocol. A quick fix, and the real one with a custom app protocol.

AngularTypeScriptFrontendWebdev

ng serve plus loadURL("http://localhost:4200") works on the first try. Then you run electron-forge make, launch the packaged app, and get a blank window with a wall of 404s for main-.js and styles-.css in DevTools. Angular is innocent. Three paths drifted apart: where the CLI writes the build, what the base href says, and where main loads index.html from.

Last week was about a typed preload bridge for IPC. Its verdict promised the next step: packaging and Angular asset paths in a production bundle. This is that post, same Electron shell.

The problem: three paths that don't matter in dev

In dev, Angular's dev server serves everything from http://localhost:4200, so the app root is the server root. Once packaged, there's no server, and three landmines go off.

1. The build isn't where you think it is. The application builder (the default for new projects since Angular 17) writes the bundle to dist/project-name/browser, not dist/project-name like the old browser builder did. The docs call this out under Output location changes: Angular application build system. A script that copies all of dist/project-name into the Electron package ships a folder with a browser subfolder inside, and loadFile points at nothing.

2. Base href versus the file protocol. The CLI's default index.html sets the base href to /. Over HTTP that's the server root. Over file:// it's the filesystem root (the drive root on Windows), so main-.js gets looked up somewhere far away from index.html.

3. History API routing. PathLocationStrategy is the Angular router's default (LocationStrategy in the docs). Under file://, reloading the window on /settings means asking for a file called /settings, which doesn't exist. In a browser, a server-side fallback saves you. In Electron there's no server unless you build one.

The quick fix: file://, a relative base href, and hash routing

The shortest path to a working package:

# build with a relative base href; output lands in dist/my-app/browser (my-app = project name in angular.json)
ng build --configuration production --base-href ./
// src/app/app.config.ts (file:// variant)
import { ApplicationConfig } from "@angular/core";
import { provideRouter, withHashLocation } from "@angular/router";
import { routes } from "./app.routes";

export const appConfig: ApplicationConfig = {
  providers: [provideRouter(routes, withHashLocation())],
};
// electron/main.ts (fragment, file:// variant)
void win.loadFile(path.join(__dirname, "renderer", "index.html"));

It works, but it costs you. First, your URLs now look like #/settings. Second, file:// is exactly what Electron's security checklist tells you to avoid: item 18 in Security. A page on file:// can reach every file on the machine, so an XSS in your UI can read the user's files. And hardcoded absolute paths in templates, like /logo.svg, ignore the base href entirely and still aim at the drive root.

The real fix: a custom app:// protocol

Electron recommends serving local pages from a custom protocol instead of file://. You register the scheme as standard and secure, and inside protocol.handle you decide exactly which files go out. API: protocol. Since Electron 25, protocol.handle replaces the old registerProtocol methods.

The payoff: the base href stays at the default /, because the root is now app://bundle/. The router keeps using the History API, and the index.html fallback is a few lines in the handler.

// electron/main.ts (fragment)
import { app, BrowserWindow, net, protocol } from "electron";
import path from "node:path";
import { pathToFileURL } from "node:url";

// electron-dist/renderer = a copy of dist/my-app/browser
const RENDERER_DIR = path.join(__dirname, "renderer");

// before app ready, and only once
protocol.registerSchemesAsPrivileged([
  { scheme: "app", privileges: { standard: true, secure: true, supportFetchAPI: true } },
]);

// returns a path inside RENDERER_DIR, or null if the request tries to escape it
function resolveInsideRenderer(pathname: string) {
  let decoded: string;
  try {
    decoded = decodeURIComponent(pathname);
  } catch {
    return null;
  }
  const target = path.join(RENDERER_DIR, decoded);
  const rel = path.relative(RENDERER_DIR, target);
  if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
  return target;
}

function registerAppProtocol(): void {
  protocol.handle("app", (request) => {
    const { pathname } = new URL(request.url);
    // Angular routes (/settings, /projects/42) have no extension, so they get index.html
    const wanted = path.extname(pathname) === "" ? "/index.html" : pathname;
    const filePath = resolveInsideRenderer(wanted);
    if (!filePath) return new Response("Bad request", { status: 400 });
    return net.fetch(pathToFileURL(filePath).toString());
  });
}

function createWindow(): void {
  const win = new BrowserWindow({
    webPreferences: { preload: path.join(__dirname, "preload.js") },
  });
  // app.isPackaged: false under electron . and forge start, true in the packaged app
  void win.loadURL(app.isPackaged ? "app://bundle/" : "http://localhost:4200");
}

app.whenReady().then(() => {
  registerAppProtocol();
  createWindow();
});

What's going on here:


  • app.isPackaged is the documented way to tell dev from prod. No custom flags, no guessing from NODE_ENV.

  • The path.relative check is the same guard Electron's docs show next to protocol.handle. A request with an encoded slash, like app://bundle/..%2F..%2Fsecret.txt, tries to escape the build after decodeURIComponent and gets a 400.

  • The preload still points at the compiled preload.js next to main.js, same as last week.

  • Build and package: wire the paths in one place


Electron doesn't package anything by itself. The official tutorial walks you through Electron Forge (electron-forge import, then make): Packaging Your Application. electron-builder works too, and the rule is the same. The package needs the compiled main, the preload, and the contents of the browser folder.

An example layout (folder names are just a convention):

# 1. main.ts + preload.ts -> electron-dist/main.js and electron-dist/preload.js
npx tsc -p electron/tsconfig.json

# 2. Angular: output lands in dist/my-app/browser
ng build --configuration production

# 3. copy the CONTENTS of browser as electron-dist/renderer, not all of dist/my-app
rm -rf electron-dist/renderer
cp -r dist/my-app/browser electron-dist/renderer

# 4. in package.json: "main": "electron-dist/main.js", then
npm run make

When something's off, don't guess. If your Forge config has asar: true, the packaged code lives in resources/app.asar, which Electron treats as a virtual directory. A listing tells you right away whether renderer/index.html made it in:

# the out/ path depends on your platform and app name
npx @electron/asar list out/my-app-linux-x64/resources/app.asar | grep renderer/index.html

Pitfalls and trade-offs

The "no extension means route" heuristic. It's cheap and never touches the disk, but a route with a dot in it (say /files/report.pdf as an Angular route) gets treated as a file request and fails. In that case, check whether the file exists instead of looking at extname, and only fall back to index.html when it doesn't.

Custom sessions. The protocol is registered on the default session. If your window sets a partition in webPreferences, register the handler on session.fromPartition(...) too, or app:// simply won't answer. That's in the protocol docs as well.

registerSchemesAsPrivileged runs before ready. And only once. Without the standard privilege, relative URLs won't resolve, and localStorage, IndexedDB and cookies are disabled for that scheme by default.

CSP. Under file:// you're stuck with a meta tag, because there's no HTTP header to set (checklist item 7). With a custom protocol you own the response, so you can add a Content-Security-Policy header in the handler.

IPC validation. A fixed app://bundle origin in prod makes checklist item 17 (validate the sender in every ipcMain.handle) easier: you have one concrete value to compare against.

ASAR is read-only. Don't write anything next to __dirname in prod. User data goes to app.getPath("userData").

Verdict

A blank window after packaging Angular into Electron is almost always a path problem, not a framework problem. The quick fix (--base-href ./, hash routing, loadFile) is fine for a demo. For anything that ships to real users, serve the build over app:// with a path guard and an index.html fallback. You keep the default base href and normal URLs, and you end up doing exactly what Electron's checklist recommends.

So the desktop side now has an IPC bridge and a working package. The next sensible stop on this line is mobile: the same Angular app through Ionic and Capacitor.