Do React + GraphQL ao instalador Windows: empacotando o Financy com Electron
Daniel Garcia – 10 Mar. 2026
Nunca tinha usado Electron na vida, e no projeto eu ainda não tinha nenhuma estrutura pronta para desktop. O que eu tinha era a vontade: transformar o app web em um app desktop offline, com instalador para Windows, para o usuário usar sem abrir o navegador. Se você está pensando em fazer algo parecido, este post pode ajudar: conto o contexto do projeto, o que precisei mudar no front e no back, como tudo se encaixa no desenvolvimento e no instalador e, por fim, os problemas que apareceram e como resolvi cada um.
Não sou especialista em Electron e não tenho certeza se todas as abordagens que escolhi foram de fato as melhores — minha preocupação era ter um app funcional. Se alguém com mais experiência no ecossistema puder sugerir melhorias ou alternativas, sugestões são sempre bem-vindas (pode comentar aqui no post ou me mandar uma mensagem).
De que projeto estou falando?
O Financy é um app de finanças pessoais: o usuário cadastra categorias (receitas e despesas), registra transações e acompanha um resumo por período em um dashboard. A stack que já existia era:
- Frontend: React com TypeScript, Vite, Apollo Client para GraphQL, React Router, Tailwind CSS.
- Backend: Node.js com TypeScript, Express, Apollo Server, TypeGraphQL, Prisma e SQLite.
Ou seja: uma API GraphQL na porta 4000 e uma SPA que consome essa API. A ideia era manter essa mesma base e, em cima dela, ter uma “janela desktop” que o usuário instala no Windows e usa offline, sem precisar abrir o navegador nem rodar nada no terminal.
O que mudou no frontend?
O frontend em si (telas, GraphQL, estado) continuou igual. O que mudou foi a forma de carregar e de rotear quando o app deixa de ser servido por um dev server (localhost:5173) e passa a ser aberto a partir de arquivos estáticos no disco.
Base path no Vite
Novite.config.tsdefinibase: './'. Assim, no build, os assets (JS, CSS, imagens) passam a ser referenciados com caminhos relativos. Quando o Electron carrega oindex.htmlviafile://, o navegador consegue resolver esses caminhos e a aplicação carrega sem ficar em branco.Router: HashRouter no desktop, BrowserRouter em dev
Com protocolofile://, não existe servidor para fazer fallback de rotas: um refresh em/dashboardquebraria. Por isso nomain.tsxdo frontend usei HashRouter quandowindow.location.protocol === 'file:'e BrowserRouter quando forhttp:(desenvolvimento). No desktop, as rotas ficam tipofile:///.../index.html#/dashboard, e o React Router segue funcionando normalmente.API GraphQL
O Apollo Client continua apontando parahttp://localhost:4000/graphql. Em desenvolvimento o backend roda na 4000; no app instalado, o backend sobe dentro do próprio Electron na mesma porta. Para o frontend não muda nada: em ambos os casos a API está em localhost:4000.
O que mudou no backend?
O backend continuou sendo a mesma API GraphQL (resolvers, Prisma, autenticação). As mudanças foram para ele rodar de dois jeitos: sozinho no terminal (como hoje) e dentro do processo do Electron quando for o app instalado.
Exportar
startServer(options)
A lógica que antes ficava só dentro de umbootstrap()virou uma funçãostartServer(options)exportada, que recebe opções comoport,databaseUrl,corsOrigin,emitSchemaFile(e o que mais for necessário). Dentro dela, o Express e o Apollo Server são configurados e oapp.listen()retorna ohttp.Server, para o Electron poder chamarserver.close()ao fechar o app.Chamar
startServer()só quando for o entry point
Para não quebrar o uso “normal” do backend (por exemplonpm run devcom tsx ounode dist/src/index.js), oindex.tssó chamastartServer()quando o arquivo está sendo executado como entry point. Isso é detectado peloprocess.argv[1]: se terminar emindex.jsouindex.ts, é o main e sobe o servidor; caso contrário (quando o arquivo é importado pelo Electron), não sobe nada, só exporta a função.Imports relativos com extensão
.js
O backend usa ESM ("type": "module"). No Node, imports relativos precisam da extensão. Então em todo import relativo (resolvers, services, graphql/context, etc.) usei.jsno final (por exemplo'./resolvers/health.resolver.js'). O TypeScript continua resolvendo os tipos pelos.ts; o código emitido fica correto para o Node no app empacotado.Banco no app instalado
No desktop, oDATABASE_URLaponta para um SQLite na pasta de userData do Electron (por exemplo.../AppData/Roaming/Financy/financy.db), em vez de um banco de desenvolvimento. O backend não precisa “saber” se está em dev ou no instalador: ele só recebe a URL do banco via opções ou env.
Como fica o desenvolvimento no dia a dia
Em dev continuo com três terminais:
- Backend:
cd backend && npm run dev— sobe a API GraphQL na porta 4000 (e roda migrações se precisar). - Frontend:
cd frontend && npm run dev— Vite na 5173 com hot reload. - Desktop:
cd desktop && npm run dev— compila o main process do Electron e abre a janela. A janela carregahttp://localhost:5173, ou seja, o mesmo frontend que está rodando no Vite. O frontend na janela fala com o backend na 4000. Nada de arquivos estáticos nemfile://em dev.
Assim você desenvolve com a mesma experiência de antes: altera o frontend ou o backend e vê o resultado na janela do Electron sem precisar gerar instalador.
Como montei o instalador
Montei o fluxo do instalador em cima de scripts e da configuração do electron-builder:
Build do backend
Um script (por exemploprepare-backend.js) roda o build do backend (prisma generate,tsc), copiadist,prismaepackage.jsonpara uma pasta (por exemplobackend-packaged) e rodanpm install --omit=devnela. Essa pasta é o “backend pronto para produção” que vai dentro do instalador.Build do frontend
O Vite gera o build do frontend (combase: './'). Os arquivos gerados são copiados para uma pasta que o Electron usa (por exemplofrontend-distdentro do projeto desktop).electron-builder
O builder empacota o código do desktop (main process), a pasta do frontend buildado e, em extraResources, a pasta do backend (por exemplobackend-packagedcopiada comobackendemresources). No Windows, isso vira o instalador NSIS que o usuário executa.O que acontece quando o usuário abre o app instalado
O processo principal do Electron inicia e, como está empacotado:- Roda as migrações do Prisma (spawn do Node com o CLI do Prisma, usando como
cwda pastaresources/backendeDATABASE_URLapontando para o SQLite na userData). - Faz dynamic import do entry do backend (
resources/backend/dist/src/index.js) e chamastartServer({ port: 4000, databaseUrl, emitSchemaFile: false }). O servidor sobe no mesmo processo do Electron, sem depender de ter o Node no PATH. - Cria a janela e carrega o frontend via
win.loadFile(indexHtml)— ou seja, oindex.htmldos arquivos estáticos que foram copiados. O frontend carrega com protocolofile:, usa HashRouter e continua chamandohttp://localhost:4000/graphql. O backend já está ouvindo nessa porta.
- Roda as migrações do Prisma (spawn do Node com o CLI do Prisma, usando como
No fim das contas, no instalador tudo funciona: uma única janela, backend e frontend integrados, banco local na máquina do usuário.
Problemas que apareceram (e como resolvi)
Foi nesse caminho que esbarrei nos problemas abaixo — e nas soluções que adotei.
1. No Windows, o backend nem subia (ERR_CONNECTION_REFUSED)
O que acontecia: No app instalado, a interface abria, mas as requisições para http://localhost:4000/graphql falhavam com ERR_CONNECTION_REFUSED. O backend não estava no ar.
Por quê: A primeira ideia foi subir o backend como processo separado com spawn("node", [entryPath], ...). No Windows, ao abrir o app pelo atalho ou pelo executável, o processo do Electron não herdava um ambiente com o Node no PATH. O spawn não encontrava o node ou falhava, e o backend nunca iniciava.
O que fiz: Deixei de depender do Node no PATH. O backend passou a ser carregado e iniciado dentro do processo principal do Electron (dynamic import do entry e chamada a startServer()). O servidor sobe no mesmo processo, sem precisar de um node externo. As migrações continuaram via spawn do Prisma (nesse caso ainda é preciso ter o Node acessível no PATH ou outra estratégia futura).
2. Porta 4000 em uso ao reabrir o app
O que acontecia: Ao fechar o app e abrir de novo, aparecia erro de “Backend não iniciou” ou “porta 4000 em uso”. Em alguns cenários parecia um loop de tentativas de subir e matar processos.
Por quê: Com o backend em processo separado, ao fechar o Electron o processo do backend não era encerrado corretamente. Sobrava um processo “zumbi” segurando a porta 4000; na próxima abertura o novo backend não conseguia subir.
O que fiz: Com o backend in-process, não há processo separado para matar. No encerramento do app (window-all-closed e before-quit), chamei server.close() no http.Server retornado por startServer(), liberando a porta. Na próxima abertura a porta está livre e o servidor sobe de novo.
3. “Cannot find module” no app instalado (health.resolver, etc.)
O que acontecia: Depois de colocar o backend in-process, ao abrir o app instalado aparecia Cannot find module para arquivos como health.resolver (ou outros resolvers). O index.js do backend carregava, mas os imports internos (por exemplo ./resolvers/health.resolver) não eram encontrados.
Por quê: Com "type": "module", o Node não adiciona .js sozinho nos imports. O TypeScript compila sem alterar os paths, então o código gerado continua com from './resolvers/health.resolver'. O Node procura um arquivo com esse nome literal (sem extensão) e não acha — o que existe é health.resolver.js.
O que fiz: Incluí a extensão .js em todos os imports relativos do backend (e para pastas com index, usar context/index.js explicitamente). O TypeScript continua resolvendo os tipos pelos .ts; o emitido fica compatível com a resolução ESM do Node no app empacotado.
4. npm run dev não subia o servidor
O que acontecia: Em desenvolvimento, ao rodar o backend com tsx watch src/index.ts, o servidor não subia e não aparecia a mensagem “Servidor iniciado na porta 4000!”.
Por quê: A condição para chamar startServer() era algo como process.argv[1]?.endsWith('index.js'). No app empacotado o entry é dist/src/index.js; em dev o comando é tsx watch src/index.ts, então o path em argv[1] termina em index.ts. A condição falhava e o servidor não era iniciado no dev.
O que fiz: Passei a considerar os dois casos como entry: entry.endsWith('index.js') || entry.endsWith('index.ts'). Assim tanto o build/desktop quanto o npm run dev com tsx disparam o startServer(). Aproveitei e ajustei o fallback da porta para não usar Number(process.env.PORT) ?? 4000 (que vira NaN quando PORT não está definido), usando process.env.PORT ? Number(process.env.PORT) : 4000.
Resumindo
| Problema | Causa | Solução |
|---|---|---|
| Backend não sobe no Windows (ERR_CONNECTION_REFUSED) | spawn("node", ...) sem Node no PATH no app instalado |
Backend in-process: dynamic import + startServer() |
| Porta 4000 em uso ao reabrir o app | Processo do backend não era encerrado | Sem processo separado; server.close() no quit |
| Cannot find module (health.resolver, etc.) | ESM no Node exige extensão nos imports relativos | Incluir .js em todos os imports relativos do backend |
npm run dev não inicia o servidor |
Condição “main” só considerava index.js |
Considerar index.js ou index.ts como entry |
No fim, consegui gerar o instalador funcional para Windows: o usuário instala, abre o app, as migrações rodam (quando houver Node no PATH para o spawn do Prisma), o backend sobe dentro do Electron e a interface React consome a API GraphQL em localhost:4000. A mesma base (React + Vite + GraphQL + Apollo Server) segue funcionando em desenvolvimento com dois terminais; no desktop, vira um único executável com tudo empacotado — tudo isso sem ter partido de uma estrutura pronta nem de experiência prévia com Electron, só da vontade de ter o app web como desktop offline e funcional.
Para dar uma ideia do resultado, abaixo estão as métricas do app na versão desktop: uso de memória dos processos Electron em execução, tamanho do executável gerado e espaço ocupado após a instalação.

Uso de memória em execução (~131,3 MB nos processos Electron), tamanho do executável Windows (174,6 MB) e espaço em disco após instalação (625 MB).
Mais?
- Quer ver o merge request com todas as alterações (e o fluxo de commits caótico)? Pull Request — Desktop App.
- Documentação do electron-builder.
- Node.js ESM e extensão .js nos imports.
- Quer trocar uma ideia? Manda uma mensagem ou me encontre nas redes sociais.