If you want to build an Electron app's UI in modern Ember (Glimmer components, .gts, template tag, Vite), there is no official path for it. The old answer, ember-electron, is built on classic ember-cli plus webpack and has effectively stalled: its last release was March 2024 and the only commits since are dependabot bumps, so it never gained a Vite or .gts story. Meanwhile electron-vite ships blueprints for Solid, Svelte, Vue, and React, but not Ember.
The good news: Embroider ships its own Vite plugin, and you can drop it straight into electron-vite's renderer. The catch is that the obvious way to wire it up drags in the whole ember-cli and broccoli toolchain, which is a lot of machinery for an Electron renderer. This is the lean way instead: Embroider's ember() plugin plus ember-strict-application-resolver, no ember-cli, no broccoli, no compat layer. Note that while this choice is made, in theory nothing stops you from setting up a full-fledged Ember app blueprint in Electron. It's just more to write out here.
Thanks to @NullVoxPopuli, whose smol-ember-app is the reference this is based on.
The shape of it
electron-vite gives you one config with three targets (main, preload, renderer). Only the renderer cares about Ember, and its whole framework story is Embroider's own plugin:
// electron.vite.config.ts
import { resolve } from 'path';
import { defineConfig } from 'electron-vite';
import type { Plugin } from 'vite';
import { babel } from '@rollup/plugin-babel';
import { extensions, ember, optimizeDeps } from '@embroider/vite';
export default defineConfig({
main: {},
preload: {},
renderer: {
root: resolve(__dirname, 'src/renderer'),
base: './',
resolve: {
alias: { '@': resolve(__dirname, 'src/renderer') },
extensions,
},
optimizeDeps: optimizeDeps(),
plugins: [
// @embroider/vite types its plugins against its own bundled Vite
// version, which trips electron-vite's stricter defineConfig typing.
// Runtime is fine; the cast just satisfies tsc.
ember() as unknown as Plugin,
babel({
babelHelpers: 'inline',
extensions,
// See the gotchas: without an explicit configFile the template
// compiler silently stays in eval mode.
configFile: resolve(__dirname, 'src/renderer/babel.config.cjs'),
}),
],
},
});
That is the entire build wiring. No hand-rolled resolver, no content-tag plumbing. ember() owns module resolution and .gts compilation; babel does the TypeScript strip and template precompilation.
The dependencies
pnpm add -D @embroider/vite @embroider/core @embroider/macros ember-source \
ember-strict-application-resolver \
@rollup/plugin-babel @babel/core @babel/plugin-transform-typescript \
babel-plugin-ember-template-compilation decorator-transforms
Note what is not here: ember-cli, @embroider/compat, ember-auto-import, broccoli. That is the whole point.
The app is a modules map
Instead of the classic filesystem resolver (which needs the ember-cli build to generate its module map), you use ember-strict-application-resolver. You declare every module the framework looks up by name, explicitly, in one place:
// src/renderer/app.gts
import Application from 'ember-strict-application-resolver';
import Router from './router';
import ApplicationTemplate from './templates/application';
export class App extends Application {
modules = {
'./router': Router,
'./templates/application': ApplicationTemplate,
};
}
Only things resolved by name go in the map: the router, templates, routes, and services. Components and helpers used inside .gts templates are plain imports (strict mode), so they never touch the map.
This example has a single template to keep it short, but it is a real Ember app underneath. Add routes, services, controllers, and more templates to the modules map and you have the full framework: a router with many pages, dependency injection, the lot. We are only keeping it to one ping here for brevity.
Router and templates
The router is stock Ember:
// src/renderer/router.ts
import EmberRouter from '@ember/routing/router';
export default class Router extends EmberRouter {
location = 'hash';
rootURL = '/';
}
Router.map(function () {});
And a template is boring .gts. Here is the classic Electron ping, talking to the main process over the preload bridge:
// src/renderer/templates/application.gts
import { on } from '@ember/modifier';
const ping = (): void => window.electron?.ipcRenderer.send('ping');
<template>
<h1>Hello from Ember</h1>
<button type="button" {{on "click" ping}}>Ping</button>
</template>
Bootstrapping
index.html imports the app and creates it. No {{content-for}}, no vendor script:
<body>
<script type="module">
import { App } from './app.gts';
App.create({});
</script>
</body>
Any one-time setup goes at the top of app.gts, module-load, before App.create runs.
The babel config
// src/renderer/babel.config.cjs
const { buildMacros } = require('@embroider/macros/babel');
const macros = buildMacros();
module.exports = {
plugins: [
['@babel/plugin-transform-typescript', {
allExtensions: true,
allowDeclareFields: true,
onlyRemoveTypeImports: true,
}],
['babel-plugin-ember-template-compilation', {
compilerPath: 'ember-source/ember-template-compiler/index.js',
targetFormat: 'wire',
transforms: [...macros.templateMacros],
}],
['module:decorator-transforms', {
runtime: { import: require.resolve('decorator-transforms/runtime-esm') },
}],
...macros.babelMacros,
],
generatorOpts: { compact: false },
};
The gotchas that cost real time
Five things bit us, and none of them are obvious from the docs.
1. Use ember() alone, not classicEmberSupport(). Every Embroider example pairs them. But classicEmberSupport() runs the classic broccoli build and immediately errors with "You have to be inside an ember-cli project." Drop it. ember() on its own, with the strict resolver, is the whole lean path. You only add classicEmberSupport() back if you need classic v1 addons (see below).
2. index.html is plain HTML. The {{content-for "head"}} placeholders and the @embroider/virtual/vendor.js / vendor.css links you see in most Ember apps are classic-build artifacts. Without classicEmberSupport() they are unsupported and the build fails on them. Delete them. A module script that imports your app is all you need.
3. Point babel at its config file explicitly. electron-vite runs with the electron package as its cwd, but the babel config lives in the renderer root. Babel resolves babel.config.cjs from cwd, does not find it, and applies nothing. The build still "succeeds," but your templates are never precompiled. Pass configFile on the babel plugin so it is actually read.
4. targetFormat: 'wire' is not optional under Electron. This is the big one. Without wire format, the template compiler resolves template scope with eval at runtime. A normal web app shrugs. A packaged Electron app with a script-src 'self' Content Security Policy (which you want) blocks eval, and the whole UI silently fails to boot. Wire format precompiles the scope, so there is no eval in the bundle. After setting it, grep your built assets for eval(: you want zero.
5. Exclude template-shipping addons from dev pre-bundling. In production this does not bite, but in dev Vite pre-bundles node_modules with esbuild, which skips the Ember pipeline. Any addon that ships raw precompileTemplate calls (icon sets, for example) then throws "Attempted to call precompileTemplate at runtime" on boot. optimizeDeps() from @embroider/vite wires the esbuild resolver; for those specific addons, add them to its exclude so they route through babel like your own code:
optimizeDeps: (() => {
const base = optimizeDeps();
return { ...base, exclude: [...(base.exclude ?? []), 'my-icon-addon'] };
})(),
There is one Electron-specific choice too: use hash location (location = 'hash'). The packaged pages are served over a custom protocol (or file://), and a single index.html plus hash routing means the main process can point a window at app://index.html#/some-route with no server-side route mapping. History location would need every route to resolve to a real path.
When you do want classic addons
The strict resolver is the right call when your renderer uses modern, v2 addons. If you depend on a classic v1 addon that expects the ember-cli build, you cross back into full-app territory: add ember-cli-build.js, config/targets.js, the ember-cli and @embroider/compat dev deps, and pair classicEmberSupport() with ember(). That is a second ember-cli app living inside your Electron package. It works, but it is a lot of machinery, so only reach for it when a dependency forces your hand.
The short version
-
electron-vite is just Vite. Ember lives in the
renderertarget and nothing about it is Electron-specific except the routing choice. -
Use Embroider's real
ember()plugin, not a hand-rolled one, and pair it withember-strict-application-resolverso you skip ember-cli, broccoli, and compat entirely. -
The app is a
modulesmap, and it scales from one ping template to a full router-and-services app. -
Wire-format templates (
targetFormat: 'wire') are mandatory under Electron's CSP. No eval in the bundle. -
Watch the babel
configFilewhen your Vite root is not the cwd, and exclude template-shipping addons from dev pre-bundling. -
Hash routing so one
index.htmlserves every page under a custom protocol.
Modern Ember, on Embroider's own tooling, without the ember-cli weight.
Disclaimer: AI written, human reviewed
Top comments (1)
This is fascinating! How does the Ember