Electron: transformando um site React em app desktop
Olá meus Unicórnios! 🦄✨
Sabe quando você olha para um site React que já funciona lindamente no navegador e pensa "é só embrulhar isso num Electron, meia hora de trabalho"? 😅 Pois é. Eu pensei exatamente isso, e a meia hora virou umas boas semanas.
Não porque o Electron seja difícil — abrir uma janela e carregar uma página são literalmente sete linhas. O problema é que o navegador embutido do Electron não é o Chrome que você testou, e tudo o que a sua SPA assumia sobre "estar num site" deixa de valer: as rotas do react-router quebram, chamadas de API param de se comportar, e o app entra em modo offline estando online.
Neste artigo vou mostrar o caminho que deu certo, com o código real de um app de notas meu — um monorepo com frontend/ (React + Vite), backend/ (Express) e electron/. E vou contar principalmente onde eu me enganei, porque é isso que documentação nenhuma conta.
Se você prefere o código antes da explicação, montei um exemplo completo e funcional no GitHub — a mesma SPA rodando no navegador e como app desktop:
🎯 A decisão que define tudo: como o app carrega o frontend
Antes de escrever qualquer linha, você precisa responder uma pergunta. Todo o resto decorre dela.
Existem três jeitos de o Electron mostrar sua SPA, e eles não são equivalentes:
loadURL("https://meusite.com")— o app vira um navegador dedicado. Simples, mas sem internet o usuário vê a tela de dinossauro. E você não ganha quase nada por ter empacotado.loadFile("dist/index.html")— carrega do disco. Funciona offline, mas a origem virafile://, e aí tudo quebra: semfetchrelativo, sem IndexedDB confiável, sem service worker, sem contexto seguro.- Um protocolo customizado, tipo
app://notas— você registra um esquema próprio e serve os arquivos do disco por ele. Funciona offline e tem uma origem estável, tratada como contexto seguro.
Escolhi a terceira. E se você tem uma SPA de verdade — com rotas e chamadas de API — é a única que sobrevive.
Repare no detalhe que me custou tempo: o esquema precisa ser marcado como privilegiado antes do app.whenReady(). Não é "boa prática" — é obrigatório. Se você chamar depois, o Chromium já decidiu que app:// é um esquema exótico e vai recusar fetch e streams.
// electron/src/appProtocol.ts
import { protocol } from "electron";
import { APP_SCHEME } from "./config.js";
/**
* Marca o esquema app:// como privilegiado ANTES do app.ready: tratado como origem
* segura (https-like), com suporte a fetch/streams. Deve ser chamado no topo do
* módulo principal, antes de app.whenReady().
*/
export function registerAppScheme(): void {
protocol.registerSchemesAsPrivileged([
{
scheme: APP_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true,
},
},
]);
}
E no main.ts, a chamada fica solta no topo do arquivo — fora de qualquer whenReady:
// Esquema app:// precisa ser privilegiado ANTES do ready.
registerAppScheme();
Essas cinco flags de privileges parecem burocracia, mas cada uma resolve um sintoma específico: standard faz a URL ter host e path de verdade (sem ela, new URL() se perde), secure convence o Chromium de que é contexto seguro (senão adeus crypto.subtle e service worker), supportFetchAPI libera o fetch, corsEnabled evita bloqueio nas próprias requisições e stream permite responder com corpo em streaming.
🗺️ Servindo a SPA: três casos e um fallback
Registrado o esquema, falta o handler que responde às requisições. Ele resolve três coisas em ordem — e a terceira é o pulo do gato do react-router.
export function registerAppProtocol(): void {
const root = frontendDir();
protocol.handle(APP_SCHEME, async (request) => {
const url = new URL(request.url);
const pathname = decodeURIComponent(url.pathname);
// 1) Chamadas de API → proxy para o backend.
if (pathname === "/api" || pathname.startsWith("/api/")) {
return proxyApiRequest(request, pathname);
}
// 2) Asset estático, se existir no disco (impede path traversal). O guard usa
// `root + path.sep` para não casar diretórios-irmãos cujo nome começa com root
// (ex.: ".../dist" vs ".../distEVIL").
const candidate = path.normalize(path.join(root, pathname));
const inRoot = candidate === root || candidate.startsWith(root + path.sep);
if (inRoot && pathname !== "/" && fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return net.fetch(pathToFileURL(candidate).toString());
}
// 3) Fallback SPA → index.html (react-router resolve a rota no cliente).
return net.fetch(pathToFileURL(path.join(root, "index.html")).toString());
});
}
Três observações sobre esse trecho, porque cada uma vale um bug:
O fallback do passo 3 é o que faz o react-router funcionar. No servidor web você configura isso no nginx ou no .htaccess — "qualquer rota que não seja arquivo, devolva o index.html". No Electron, esse papel é seu. Sem ele, dar F5 em /notas/123 devolve 404 e a tela fica branca.
O guard do passo 2 usa root + path.sep, não só root. Parece paranoia, mas startsWith(root) puro deixaria passar um diretório irmão chamado distEVIL quando a raiz é dist. É barato blindar e caro descobrir depois.
A comparação pathname !== "/" não é decorativa. Sem ela, a raiz cairia no existsSync de um diretório, e o statSync().isFile() devolveria false — funcionaria por acidente. Deixar explícito é mais honesto que depender do acaso.
Falta ainda saber onde o frontend compilado mora, e a resposta muda entre desenvolvimento e app instalado:
/**
* Resolve a pasta do frontend compilado.
* - Empacotado: process.resourcesPath/frontend (electron-builder extraResources).
* - Dev: ../frontend/dist relativo a este arquivo (electron/dist/appProtocol.js).
*/
export function frontendDir(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "frontend");
}
// electron/dist/appProtocol.js → sobe para electron/, depois ../frontend/dist
return path.resolve(__dirname, "..", "..", "frontend", "dist");
}
🔀 O proxy: por que a API não pode ir direto
Se você só for ler um pedaço deste artigo, leia este. 🙏
No desenvolvimento, o Vite tem aquele server.proxy que manda /api/* para o backend. É o que faz a SPA e a API compartilharem a mesma origem — e é por isso que tudo funciona tão suavemente com fetch("/api/notas").
No app empacotado, esse proxy não existe mais. O Vite ficou no desenvolvimento; o que roda é um bundle estático. E aí seu fetch("/api/notas") vira app://notas/api/notas, que não é lugar nenhum.
A saída foi reconstruir o proxy do Vite dentro do processo principal — é o passo 1 do handler que você viu acima. Toda chamada /api/* é encaminhada ao backend real, e a resposta volta ao renderer:
// electron/src/proxy.ts
export async function proxyApiRequest(request: Request, pathname: string): Promise<Response> {
// pathname começa com "/api". Monta a URL real no backend.
const target = backendUrl() + pathname + new URL(request.url).search;
// Clona headers, removendo os hop-by-hop / específicos de origem custom.
const headers = new Headers(request.headers);
headers.delete("host");
headers.delete("origin");
headers.delete("referer");
const init: RequestInit = {
method: request.method,
headers,
redirect: "follow",
// GET/HEAD não têm corpo; demais métodos repassam o body bruto.
body: request.method === "GET" || request.method === "HEAD"
? undefined
: await request.arrayBuffer(),
};
// ... encaminha e devolve (ver adiante — este trecho nunca pode lançar)
}
Os três headers.delete() não são frescura. O Host apontaria para o esquema customizado e confundiria o servidor; o Origin e o Referer chegariam como app://notas, e qualquer backend com checagem de origem responderia 403. Removê-los faz a requisição parecer o que ela é: uma chamada de servidor para servidor.
E repare no ternário do body: passar body num GET faz o fetch lançar TypeError. Como o proxy é genérico e recebe todos os métodos, esse undefined explícito é obrigatório.
O detalhe que eu mais gostei dessa arquitetura: o código React não mudou nada. Ele continua chamando fetch("/api/notas") exatamente como no navegador. Quem se ajeitou foi a camada de baixo — e é lá que a bagunça deve morar.
💥 O bug que me fez perder um dia: offline estando online
Este merece seção própria porque o sintoma não tinha nada a ver com a causa. 🤯
O app tem modo offline. De vez em quando, sem padrão claro, ele entrava em modo offline com a internet perfeitamente funcionando. O console do renderer mostrava um TypeError do fetch, e a rede mostrava net::ERR_UNEXPECTED.
A causa: quando o handler de protocol.handle rejeita — qualquer promise rejeitada, por qualquer motivo — o Chromium devolve net::ERR_UNEXPECTED ao renderer. E o fetch do frontend interpreta isso como falha de rede, que é o gatilho do modo offline.
Ou seja: uma exceção no meu proxy virava "você está sem internet" na cara do usuário. A regra que passei a seguir é dura e simples — o handler nunca lança:
/**
* SEMPRE resolve com uma Response — nunca lança. Um handler de protocol.handle que
* rejeita faz o Chromium devolver net::ERR_UNEXPECTED ao renderer; o fetch do frontend
* então rejeita com TypeError e o app entra em modo offline MESMO ONLINE. Por isso a
* falha de rede vira um 503 (o frontend o trata como servidor indisponível → offline,
* pelo mesmo caminho de antes, mas sem o ERR_UNEXPECTED espúrio).
*/
let upstream: Response;
try {
upstream = await fetch(target, init);
} catch (err) {
return new Response(JSON.stringify({ error: "backend unreachable" }), {
status: 503,
headers: { "content-type": "application/json", "x-proxy-error": "network" },
});
}
Repare que ele não devolve sucesso fingido: devolve 503, que o frontend já sabia tratar como "servidor indisponível". O modo offline ainda ativa quando deve — só não ativa mais por engano.
E tinha um caso ainda mais sutil que eu levei a sério só depois de ver o stack trace: o próprio new Response() pode lançar.
// Defesa: status fora de 200–599 (ex.: 0 de um opaqueredirect) faz `new Response` lançar
// RangeError → ERR_UNEXPECTED. Normaliza para 502. 204/304 nunca têm body.
const status = upstream.status >= 200 && upstream.status <= 599 ? upstream.status : 502;
const noBody = status === 204 || status === 304;
try {
return new Response(noBody ? null : upstream.body, {
status,
statusText: upstream.statusText,
headers: resHeaders,
});
} catch {
return new Response(JSON.stringify({ error: "bad upstream response" }), {
status: 502,
headers: { "content-type": "application/json", "x-proxy-error": "upstream" },
});
}
De onde vem um status 0? Do redirect. Eu tinha configurado redirect: "manual" achando que era mais "transparente" — repassar o 3xx e deixar o renderer decidir. Só que um redirect manual produz uma resposta opaqueredirect: status 0, body null. E new Response(null, { status: 0 }) lança RangeError. Que vira ERR_UNEXPECTED. Que vira modo offline. 🙃
O conserto foi uma palavra:
// SEGUE redirects no main process. Com "manual", um 3xx do backend (HTTP→HTTPS,
// trailing-slash, Cloudflare) virava uma resposta "opaqueredirect" com status 0 e
// body null; `new Response(body, { status: 0 })` lança RangeError → ERR_UNEXPECTED.
redirect: "follow",
Um HTTP → HTTPS do servidor, ou um trailing slash, ou o Cloudflare no meio — qualquer um desses derrubava o app. E o sintoma que o usuário via era "sem internet".
🌉 A ponte segura: preload com contextIsolation
Com a SPA carregando e a API funcionando, falta dar ao React acesso às coisas que só o desktop tem: gravar arquivo em disco, consultar a base local, saber que está rodando no Electron.
A tentação é ligar nodeIntegration: true e sair usando require("fs") dentro do React. Não faça isso. Sua SPA renderiza Markdown, carrega imagens, talvez tenha um editor — qualquer XSS que passe vira acesso total ao sistema de arquivos do usuário.
A configuração correta é a chata:
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
title: `Notas v${app.getVersion()}`,
icon: windowIconPath(),
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
},
});
E o preload expõe uma superfície estreita e nomeada, via contextBridge. Nada de expor ipcRenderer inteiro — isso seria o mesmo buraco com um passo a mais:
import { contextBridge, ipcRenderer } from "electron";
/**
* Bridge exposta ao renderer. Mantém contextIsolation ligado (sem nodeIntegration
* no renderer): o React só enxerga estas funções, nunca o Node.
*/
contextBridge.exposeInMainWorld("appDesktop", {
isElectron: true,
/** Consulta notas locais (SQLite) — devolve valor, por isso invoke. */
notesQuery: (opts: { limit: number; offset: number; q?: string }) =>
ipcRenderer.invoke("desktop-notes-query", opts),
/** Insere ou atualiza uma nota local. */
notesUpsert: (note: unknown) => ipcRenderer.invoke("desktop-notes-upsert", note),
/** Assina um pedido vindo do menu nativo. Retorna uma função de unsubscribe. */
onForceSync: (cb: () => void) => {
const listener = () => cb();
ipcRenderer.on("desktop-force-sync", listener);
return () => ipcRenderer.removeListener("desktop-force-sync", listener);
},
});
Duas coisas que aprendi escrevendo esse preload:
Todo on() devolve um unsubscribe. Sem isso, um useEffect do React que remonta vai acumulando listeners e um dia o callback dispara cinco vezes. Devolver a função de limpeza faz o useEffect ficar natural: return window.appDesktop.onForceSync(cb).
send é fire-and-forget; invoke devolve valor. Parece óbvio escrito assim, mas eu misturei os dois no começo e fiquei tentando entender por que o await nunca resolvia. 😳
Do lado do React, a detecção do ambiente vira uma linha só, e todo o resto do código continua igual ao da web:
// frontend/src/lib/api.ts
return typeof window !== "undefined" && !!window.appDesktop?.isElectron;
🚪 Uma janela só, por favor
Um detalhe pequeno que eu só entendi depois de corromper o cache local: duas janelas do mesmo app brigam pelo IndexedDB, porque compartilham a mesma origem app://notas. Abrir o app duas vezes bagunçava a base.
O conserto é uma chamada:
// Single-instance: evita duas janelas brigando pelo IndexedDB da mesma origem.
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on("second-instance", () => {
// Tentou abrir de novo → foca a janela que já existe.
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
void app.whenReady().then(() => {
registerAppProtocol();
createWindow();
});
}
Repare que o second-instance não é só um detalhe técnico — é comportamento esperado de app desktop. Clicar no ícone com o app já aberto deve trazer a janela para a frente, não abrir outra. É de graça e o usuário sente.
📦 Empacotando: onde o frontend some do pacote
Chegamos no electron-builder. A configuração que importa está em três chaves:
{
"build": {
"appId": "com.exemplo.notas",
"productName": "Notas",
"directories": { "output": "out" },
"files": [
"dist/**/*",
"package.json"
],
"extraResources": [
{
"from": "../frontend/dist",
"to": "frontend"
}
],
"win": {
"target": "nsis",
"artifactName": "Notas-Setup-${version}.${ext}"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
}
O extraResources é o item que eu esqueci na primeira tentativa. Repare que files só inclui dist/**/* — o build do Electron. O build do React vive em outro workspace, fora dessa pasta, e o electron-builder não vai adivinhar que você quer levá-lo junto. O app instalava lindamente e abria uma tela branca. 😖
É exatamente por isso que existe aquele frontendDir() com o if (app.isPackaged): extraResources joga a pasta em process.resourcesPath/frontend, que não tem nenhuma relação com o caminho de desenvolvimento.
E, já que dá para prever esse erro, o app avisa em vez de mostrar tela branca:
if (!frontendBuilt()) {
// Build do frontend ausente → tela explicativa em vez de tela branca.
void mainWindow.loadURL(
"data:text/html;charset=utf-8," +
encodeURIComponent(
`<html lang="pt-BR"><body style="font-family:sans-serif;padding:2rem">
<h1>Build do frontend não encontrado</h1>
<p>Rode <code>npm run build:frontend</code> e abra novamente.</p>
</body></html>`,
),
);
return;
}
Custa cinco minutos e economiza meia hora de "por que está branco?" — inclusive para você mesmo, três meses depois.
Uma dica de ergonomia que vale ouro no monorepo: amarre o build do frontend ao prestart do Electron, para nunca rodar o app com um bundle velho:
"scripts": {
"build": "tsc -p tsconfig.json",
"build:frontend": "npm run build --workspace @app/frontend",
"prestart": "npm run build:frontend && npm run build",
"start": "electron ."
}
A URL do backend precisa estar dentro do binário
Última pegadinha do empacotamento, e ela é sorrateira porque funciona na sua máquina. Meu config.ts lia process.env.APP_BACKEND_URL. Em desenvolvimento, perfeito. No app instalado na máquina do usuário: essa variável não existe.
A solução foi um arquivo que o CI reescreve no momento do build, gravando a URL dentro do código:
/**
* Config injetada no momento do BUILD (CI). Estes valores são sobrescritos pelo
* workflow de release a partir dos secrets do repositório.
*
* Em dev (build local sem CI) ficam vazios; config.ts então cai no fallback
* localhost. NÃO leia process.env aqui: env vars do CI não existem em runtime na
* máquina do usuário — por isso precisam ser fixadas neste arquivo no build.
*/
export const BUILD_BACKEND_URL = "";
E a resolução vira uma cascata de três níveis — env em dev, valor "assado" no build, e localhost como último recurso:
/** Primeiro valor não-vazio entre env, build-config e o fallback localhost. Sem barra final. */
function resolveUrl(envValue: string | undefined, bakedValue: string): string {
const raw = (envValue || bakedValue || "http://localhost:4000").trim();
return raw.replace(/\/+$/, "");
}
No CI, o passo que faz o "bake" é um node -e que troca a string do arquivo antes de compilar. E aquele replace(/\/+$/, "") parece trivial, mas evita um bug bobo e frequente: a barra final. Um secret com https://api.exemplo.com/ produziria https://api.exemplo.com//api/notas, e alguns servidores respondem 301 nisso — o que, lembra?, era justamente o caminho para o opaqueredirect e o modo offline fantasma. Os bugs conversam entre si. 😅
🩹 O botão "Estou com Problema"
Termino com o item mais humilde do app, e o que mais me poupou suporte.
Um app desktop offline-first acumula estado em vários lugares diferentes: base SQLite local, arquivos em disco, IndexedDB e cache do Chromium. Quando um deles desincroniza, o usuário vê um sintoma esquisito e você não tem console para depurar remotamente.
Então o menu Ajuda tem um item que limpa tudo o que é local — e só o que é local:
// Reset "Estou com Problema": limpa o estado local e os caches do navegador embutido.
// O renderer já limpou o IndexedDB do app antes de invocar; aqui apagamos as bases
// SQLite locais, os arquivos em disco e o storage do Chromium (cache, service workers,
// IndexedDB residual). Os dados do servidor NÃO são tocados (só o local).
ipcMain.handle("desktop-problem-reset", async () => {
clearAllNotesDbs();
clearAllFiles();
try {
await session.defaultSession.clearStorageData({
storages: ["indexdb", "localstorage", "serviceworkers", "cachestorage"],
});
await session.defaultSession.clearCache();
} catch (err) {
console.warn("[problem-reset] falha ao limpar storage do Chromium:", err);
}
});
Note a divisão de trabalho: o renderer limpa o IndexedDB dele antes de invocar, porque o processo principal não tem acesso direto ao banco da aplicação. E o clearStorageData ainda passa por cima de qualquer resíduo. Cinto e suspensório.
Junto com isso vem um menu de Debug — arquivo de log, pasta de instalação, pasta de dados — e um "Sobre" com as versões, com botão de copiar:
function aboutText(): string {
const v = process.versions;
return [
`Notas ${app.getVersion()}`,
"",
"[Runtime]",
` Electron: ${v.electron}`,
` Chromium: ${v.chrome}`,
` Node: ${v.node}`,
"",
"[Sistema]",
` Plataforma: ${process.platform} (${process.arch})`,
` Backend: ${backendUrl()}`,
"",
"[Instalação]",
` Exe: ${app.getPath("exe")}`,
` Dados (userData): ${app.getPath("userData")}`,
].join("\n");
}
Quando um usuário reporta um problema, esse texto colado no chat resolve metade das perguntas antes de eu precisar fazê-las. 🙌 E o userData ali é especialmente útil: é onde mora todo o estado local, e ninguém decora esse caminho de cabeça em três sistemas operacionais diferentes.
📂 O código completo
Todo o exemplo está no GitHub, pronto para clonar e rodar: a mesma SPA React abrindo no navegador e como app desktop, com o backend Express junto. É o mínimo que funciona — sem autenticação, sem banco, sem mágica.
Tem uma aba Diagnóstico lá dentro que vale a visita: ela mostra ao vivo o proxy limpando os headers, o redirect sendo seguido e o erro tratado quando o backend cai. Dá para trocar redirect: "follow" por "manual" e ver o bug do opaqueredirect acontecer em trinta segundos. 🔬
O que mais me marcou nessa jornada foi perceber que quase nenhum dos problemas era do Electron. Eram premissas da web que eu carreguei sem perceber: "o proxy existe", "redirect é transparente", "a env var está lá", "só existe uma janela". O Electron só foi o lugar onde essas premissas apareceram nuas. 😳
E o melhor: o código React não mudou quase nada. Uma função isElectron() de uma linha, alguns caminhos alternativos para o armazenamento local, e pronto. Toda a bagunça ficou na camada de baixo, exatamente onde ela deveria estar — que é o sinal de que o desenho está certo. ✨
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.