Executando verificação de segurança...
1

Do zero ao Play Store: decisões técnicas, bugs reais, e o que o Claude Code não conseguiu fazer por mim

Passei o carnaval finalizando a construção do Bookworm, um rastreador de leituras com web app, app Android, landing page bilíngue, backend completo com autenticação, assinaturas, gamificação e integração com e-readers. Projeto solo, do zero.

Eu estava afastado do meu hábito de leitura nos últimos anos e voltei com tudo em dezembro. Já li 7 livros desde o início de janeiro e já se foram quase 1500 páginas desde então. Por isso que eu acabei chegando no problema de como acompanhar o hábito que não fosse uma planilha de excel.

Começou pequeno e 100% manual. Você criava os livros de forma semi-automática usando a API do Google Books ou 100% manual e depois de cada sessão de leitura você atualiza o número da página que parou. A ideia era rastrear dados que eu não consigo de nenhuma outra forma concentrados no mesmo lugar. A Amazon sequer me fala quantas páginas eu li ao longo de um período, só me dava sequencias e quais eu terminei no ano. O que já existia, tipo o GoodReads, é mais uma rede social do que tracker de leitura e eu não tenho paciência pra mais redes sociais. A ideia era simplificar e tornar mais privado o acompanhamento do meu (e da minha esposa) hábito de leitura.

Construí praticamente tudo com Claude Code rodando no terminal ao meu lado. E, assim, não foi tudo flores, vou tentar explicar o que de fato mudou, onde a IA travou, e quais foram os bugs mais bizarros que tive que resolver na raça.

Se você já tentou usar AI para construir algo real e não um tutorial de TO DO list, esse artigo pode te tar mais motivos pra construir seu app/saas e no fim você vai conseguir aprender alguma coisa com o monte de erros que eu já cometi aqui.

Se você ainda não usa, não quer, não aceita IA no seu fluxo de trabalho, melhor repensar sua carreira (feliz ou infelizmente).


A Stack (e motivos)

O projeto tem quatro partes independentes:

  • Backend: NestJS + Prisma + PostgreSQL
  • Web: Vite + React Router + shadcn/ui
  • Mobile: Expo + React Native + NativeWind
  • Landing page: Vite + React + shadcn/ui (bilíngue PT/EN)

Por que NestJS e não Express puro?

Porque eu queria estrutura desde o começo. Express é ótimo, mas em projetos solo você paga o preço da arquitetura implícita mais tarde. O NestJS me forçou a pensar em módulos, guards, interceptors e injeção de dependência cedo. Essa decisão se mostrou acertada quando o backend cresceu para autenticação Firebase, assinaturas Stripe, integração com Google Play Billing, um painel AdminJS, gamificação com cron jobs e event emitter, e integração com e-readers via protocolo KoSync, para o KoReader, e REST/Webhooks para Play Books e integrações externas agnósticas.

O preço foi a curva de aprendizado inicial. Mas um mês depois eu estava adicionando features sem medo de quebrar tudo, porque cada coisa tinha seu lugar.

A migração do Next.js para Vite

Comecei o web com Next.js por inércia, é meio que o padrão quando não penso muito e por ter usado o Lovable pra fazer o primeiro mock do design eu só mantive. Migrei para Vite + React Router depois de algumas semanas porque:

  1. Não preciso de SSR. O app requer login, não tem SEO relevante nas rotas autenticadas, e a complexidade do Next.js em volta disso não me entregava nada.
  2. O tempo de HMR do Vite é ridiculamente mais rápido.
  3. React Router me dá controle total sobre o roteamento e eu prefiro um arquiovo de rotas do que um monte de pastas, fight me.

A migração foi ~4 horas de trabalho. Não precisei reescrever componente nenhum — só movi configuração, tirei as coisas do Next e ajustei as rotas. Se você está no Next.js por inércia e não usa SSR/SSG, vale questionar.

Expo para mobile

A decisão mais fácil do projeto. Expo remove a maior parte da dor do Android moderno: build system, módulos nativos, atualizações de configuração. A stack com NativeWind para estilos (mental model do Tailwind no React Native) funciona muito bem no dia a dia.

A ressalva: quando você chega nas bordas do Expo(builds de release, ProGuard, conflitos de dependência nativas) a abstração some e você está no Android cru. Falo mais sobre isso mais adiante.


Claude Code no workflow: o que mudou de verdade

Vou ser direto: o Claude Code tornou algumas classes de tarefas irrelevantes em termos de tempo. E outras continuam sendo 100% trabalho meu.

O que a IA resolveu bem

Mudanças transversais. "Adicionar paginação com offset e hasMore em todos os endpoints de busca externa" é o tipo de tarefa que leva uma tarde sozinho. Com Claude Code, você descreve o padrão, ele aplica nos 4 lugares, escreve os testes, atualiza os tipos. 20 minutos.

Módulos com padrão claro. Quando o projeto já tem um padrão estabelecido como: um módulo NestJS com controller, service, DTO e testes, adicionar o décimo módulo é mecânico. O Claude Code fez isso bem. Eu revisava, ajustava o que não estava certo e pau na máquina.

Testes. Escrever testes unitários para serviços que eu já tinha construído é entediante. A IA faz isso com qualidade razoável porque cobre os happy paths e os casos óbvios. Os edge cases complicados eu ainda tinha e tenho, que pensar e adicionar.

Acessibilidade. O projeto tinha um requisito não-negociável de acessibilidade. O Claude Code foi bom em lembrar de aria-label, role, live regions, e navegar por teclado. Muito melhor do que eu jamais faria sozinho checando cada componente manualmente.

Problemas com solução conhecida. "Esse botão no Android com allowsEditing: true no expo-image-picker está crashando em dispositivos Samsung com Android 14", descrevi o comportamento, ele encontrou a causa e propôs o fix correto. Teria levado horas/dias de debug manual sem um celular adequado e eu talvez nem conseguisse chegar na raiz do problema.

Onde a IA não ajudou

Decisões de arquitetura. Como modelar o sistema de gamificação? Os eventos devem ser síncronos ou assíncronos? O EventEmitter do NestJS ou uma fila de verdade? Onde fica a fronteira entre o que é lógica do domínio e o que é efeito colateral?

Essas perguntas não têm resposta correta gerada. Elas precisam de raciocínio sobre trade-offs específicos do projeto, e o output da IA nesse nível tende a ser genérico demais para ser útil. Você pode usar a IA como parceiro e ajuda no brainstorm, mas, no fim, a decisão é sua.

Bugs de ambiente. Quando algo quebra especificamente no build de release do Android mas não no debug, ou quando o Firebase barra requisições depois de X logins num curto período, ou quando o Prisma gera uma query ineficiente em um join específico, o Claude Code sabe falar sobre esses problemas em termos gerais, mas o debugging específico no seu ambiente ainda é trabalho manual.

Gosto e simplicidade. O código gerado é frequentemente correto mas verboso. Às vezes eu recebia uma implementação de 80 linhas onde 20 resolveriam. A parte de empurrar por simplicidade, de questionar se aquela abstração é necessária, de reconhecer que o código vai ser lido por você daqui 6 meses é uma skill que não é substituída pela IA. Apesar disso, eu ainda curto um código pendendo pelo lado mais verboso da coisa. Não muda nada na execução e pra eu me encontrar no futuro tende a ser mais fácil.


Os bugs que mais me custaram tempo

O crash silencioso do IAP no Android

Esse foi o pior. Quando um usuário tentava assinar o plano premium no Android, nada acontecia. Nenhum erro visível, nenhum log no Sentry. O fluxo simplesmente não avançava.

A causa: o endpoint POST /subscriptions/subscribe retornava productIds como Record<string, string> (ex: { monthly: 'id', annual: 'id' }). O cliente mobile tipava como string[] e chamava .forEach() em cima de um objeto. TypeError silencioso. O IAP inicializava como indisponível (isAvailable: false), e o código de fallback abria o Stripe no browser — que não funciona direito em Android.

O problema era de tipagem entre duas partes do sistema que nunca "conversavam" no type-checker porque eram contextos diferentes (backend TypeScript → mobile TypeScript). A solução foi simples. Chegar lá não foi.

Lição: em projetos full-stack onde o front e o backend são repositórios separados, você precisa de alguma estratégia de contrato compartilhado. Seja OpenAPI, seja Zod schemas compartilhados, seja qualquer coisa. A sincronização manual de tipos falhou, falha e falhará, sempre.

Firebase + Google Sign-In em mobile browsers

O signInWithRedirect do Firebase faz um redirect completo da página para o provedor OAuth e depois de volta. Em mobile browsers isso causa um reload da sessão que, dependendo do estado da aplicação, pode jogar o usuário fora do fluxo de autenticação.

A solução documentada é usar getRedirectResult() antes de registrar o listener de onAuthStateChanged. Parece simples. Mas o timing importa e se o listener já está ativo quando o resultado do redirect chega, você pode perder o estado.

No final removi o signInWithRedirect completamente e usei só signInWithPopup. Menos elegante em mobile, mas funciona de forma confiável. Às vezes a solução certa é a mais simples e ela nem sempre é a mais elegante e ta tudo bem também.

Kotlin e conflito de dependências no Android

O projeto usa AdMob (anúncios), Firebase Analytics, e Google Sign-In no mobile. Cada um tem constraints de versão do Kotlin.

Quando tentei atualizar @react-native-google-signin/google-signin para a v15, descobri que ela precisa de Kotlin 2.0. O AdMob da época não era compatível com Kotlin 2.0. Fiquei na v14 do google-signin.

Esse tipo de conflito de dependência em ecossistemas React Native é real e sub-documentado. A maneira que descobri: tentei a atualização, o build de release explodiu, binário de busca nos changelogs, achei a referência ao Kotlin 2.0, verifiquei a compatibilidade com AdMob. Duas horas.

A regra que emergiu do projeto: nunca atualize dependências nativas em mobile sem verificar a árvore de compatibilidade. Parece óbvio, mas é você VAI esquecer quando você está acostumado com o npm do frontend onde as consequências de uma atualização são muito menores e mais previsíveis.

Desenvolver e publicar apps mobile é um inferno de restrições e conflitos pra todo canto, especialmente quando não se desenvolve nativo com Kotlin/Swift.

ProGuard e builds de release

Se você usa IAP, Firebase, ou Ads no Android e não adiciona as keep rules certas no ProGuard, o release build vai crashar e o debug vai funcionar normalmente.

Isso acontece porque o ProGuard ofusca classes por padrão no release, e essas bibliotecas dependem de reflection para encontrar certas classes pelo nome. Se o nome foi ofuscado, a classe "não existe" em runtime.

As keep rules que precisam estar presentes:

-keep class com.android.billingclient.** { *; }
-keep class com.google.firebase.** { *; }
-keep class com.google.android.gms.ads.** { *; }

Não é nada difícil. Mas como o debug não reproduz o problema, se você não sabe o que procurar, fica horas debugando um crash que só acontece em produção.


Infraestrutura barata que funciona (e o que aprendi)

O backend roda num Raspberry Pi 5 (8GB RAM) em casa. Produção. Com usuários reais.

A configuração:

  • Cloudflare Tunnel: o Pi não tem portas abertas e meu ISP é muito burocrático pra isso também. A Cloudflare faz proxy, termina TLS, roteia tráfego. Sem exposição de IP, sem DDoS direto.
  • Docker + GitHub Actions: push na main → SSH no Pi → pull da imagem → restart. ~5-6 minutos de pipeline.
  • PostgreSQL no Pi com backups para Cloudflare R2.
  • Cloudflare R2 para imagens (avatares, capas de livros) com presigned URLs. O cliente faz upload direto, o servidor nunca toca nos bytes.

O gargalo que me forçou a otimizar de verdade

O Pi tem RAM limitada. O CI estava consumindo ~700MB em pico durante o build do NestJS com tsc.

Trocar para SWC (@swc/core + nest-cli.json com "builder": "swc") resolveu: 176 arquivos TypeScript compilando em ~200ms e ~200MB de RAM. Anteriormente levava ~30 segundos e ~700MB.

O Docker também: o Dockerfile original fazia npm ci duas vezes (uma no builder, outra na stage de produção). Trocar a segunda por npm prune --omit=dev cortou esse passo de 227s para 79s em ARM.

Esses números só ficaram visíveis porque o Pi é restrito o suficiente para o problema aparecer. Em uma máquina com 16GB de RAM, a ineficiência talvez nunca apareça. Às vezes restrições de recurso são um benefício: te forçam a entender o que o seu toolchain está fazendo de verdade. E, em cloud, você provavelmente vai ter ainda menos recursos do que 8GB de RAM e 2 cores de CPU.

Além da otimização do build no PI, a mudança de tsc para @swc/core diminuiu o tempo da action no Github de 20 minutos pros meus 5 atuais e também parou de estourar o limite padrão de 2GB de RAM pro runtime das actions.

O que o Pi não consegue fazer

Alta disponibilidade. Se o Pi restartar (queda de luz, reboot de atualização), tem downtime. Para o estágio atual do produto isso é aceitável pelo valor que eu pago que é basicamente zero. De qualquer forma já liguei o Pi e os roteadores da internet em NoBreaks pra ver como fica.

A decisão foi consciente: infraestrutura zero de custo fixo durante validação do produto. Quando (se) o produto crescer o suficiente, migrar para um servidor dedicado é um dia de trabalho (ou menos).


Gamificação que não enche o saco do usuário

O Bookworm tem streaks, badges, metas e títulos de leitor. Quando fui implementar isso, o maior risco não era técnico, era construir algo que parecesse manipulativo.

A decisão central: streaks são rastreadas, não punidas. Você perde uma streak e ela reinicia. Sem notificação de culpa, sem tela de "você quebrou sua sequência". A maioria dos apps de gamificação usa perda como mecanismo de retenção. Decidi não fazer isso.

Os outros princípios que guiaram o design:

  • Badges por milestones naturais: terminar o primeiro livro, ler 1000 páginas, criar a primeira lista. Coisas que acontecem organicamente, não que você persegue.
  • Títulos só sobem: baseados em totais acumulados de livros e páginas. "Leitor Iniciante" → "Leitor Assíduo" → "Estudioso" → "Bibliófilo" → etc. Você nunca perde um título.
  • Metas são opt-in: quem quer meta define. Ninguém impõe ritmo.

A implementação usa @nestjs/event-emitter com eventos como book.finished, session.created, note.created, goal.created. Cada evento dispara um evaluator que verifica se algum badge deve ser desbloqueado. Cron jobs diários atualizam o status das metas.

O isolamento da lógica de gamificação em eventos foi importante: o serviço de leitura não precisa saber que existe gamificação. Ele só emite o evento. Quem ouve e o que faz é problema de outro módulo. Isso manteve o núcleo do produto limpo.


E-readers e o protocolo KoSync

Uma das features mais técnicas foi a integração com e-readers. Leitores de e-reader (especialmente KoReader, que é open source e muito usado) têm um protocolo de sincronização chamado KoSync — basicamente um servidor HTTP simples que o KoReader chama para persistir progresso.

Implementei um servidor KoSync compatível dentro do backend do Bookworm. O e-reader configura o servidor como endpoint de sync, e os dados chegam automaticamente quando o usuário termina de ler.

O problema não técnico: como mapear um livro no e-reader para um livro no Bookworm? O e-reader identifica livros por título e autor (às vezes com ISBN, mas não sempre). O Bookworm tem sua própria base de dados. A correspondência não é trivial, títulos variam entre edições, autores têm variações de nome, etc.

Construí um sistema de "unmatched books" onde o sync funciona, os dados chegam, mas o livro fica em estado pendente até o usuário confirmar o mapeamento manual ou o sistema encontrar uma correspondência automática. É menos automático do que eu queria, mas é a vida.


Testes: o que valeu a pena e o que eu não faria de novo

Aqui o Claude Code fez hora extra

O projeto tem:

  • Backend: 63 suites / 913 testes unitários (Jest)
  • Web: 10 suites / 95 testes (Vitest)
  • Mobile: 17 suites / 301 testes (Jest)
  • E2E: 68 testes em 24 arquivos (Playwright + axe-core para acessibilidade)

O que valeu

Testes de service no backend. O NestJS com Prisma tem uma superfície de efeitos colaterais bem definida — mockar o PrismaService é simples, e os testes de service me pouparam várias regressões.

E2E com Playwright. A suite E2E com Playwright detectou alguns problemas de fluxo que os testes unitários nunca pegariam — como o problema do signInWithRedirect em mobile browsers, ou o estado incorreto depois de uma compra cancelada.

O que eu faria diferente

Os testes de mobile com React Native Testing Library têm um custo de manutenção alto comparado com o que entregam. Testei principalmente serviços e hooks. A camada de UI em React Native muda frequentemente e manter snapshots e testes de renderização é trabalho chato que quebra a toda atualização de UI. Testes de serviço e integração valem mais o custo.

O que eu ainda estou fazendo

Estou configurando uma suite de "testes manuais" que seriam necessários ser feitos com um usuário usando o app mas que eu posso, de forma até bem tranquila, automatizar usando o MCP do Playwright no Claude Code. A ideia é manter a suite de testes sempre atualizada como um checklist em arquivo .MD que o agente de "teste manual" vai ler e executar.


O que eu faria diferente

Contratos de tipo compartilhados mais cedo. O bug do IAP (e alguns outros menores) veio de uma dessincronia entre o que o backend retorna e o que o mobile espera. Um schema compartilhado (Zod ou OpenAPI com geração de tipos) teria eliminado esses problemas.

Não começar no Next.js. A migração foi tranquila, mas foi trabalho que eu não precisaria ter feito. Escolha a stack que você precisa de verdade, não a default.

Automatizar o bump de versão antes da primeira release. Fiz o primeiro release do Android manualmente, aumentando o versionCode e a versão exibida à mão. Automatizei depois. Deveria ter sido o primeiro passo.

ProGuard rules no template desde o início. Descobri o problema em produção. Deveria estar na configuração base de qualquer projeto React Native com IAP.


Conclusão

O Bookworm está em beta fechado no Play Store (quem puder me ajudar testando me manda uma mensagem ou comenta seu email que eu libero o link) e disponível na web em bookwormtracker.com.

O aprendizado principal depois do app construído isso: a IA mudou o custo de implementação de features bem definidas, mas não mudou o custo de pensar, de debugar ambiente, de tomar decisões de arquitetura, ou de entender por que um build de release quebra e o debug não. Essas partes continuam sendo o trabalho humano por trás da coisa toda.

Se você está construindo algo solo e tem dúvidas sobre qualquer parte da stack ou das decisões, comenta. Aberto para discutir sobre tudo e aceitando sugestões para o próprio app sempre.

PS.: O projeto, como dito, é freemium então algumas features estão bloqueadas atrás de um paywall bem singelo. Pros meus amigos leitores aqui que quiserem me ajudar a manter o projeto e propagar estatísticas literárias por aí, podem usar o cupom TABNEWS para ter um desconto no seu primeiro pagamento e liberar todas as features.


O código do backend, frontend, mobile e landing page estão em repos privados por enquanto mas, não descarto abrir alguma parte, ou todo, futuramente pra apanhar nas revisões de código.

Carregando publicação patrocinada...
1