<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Artigos | Carlos Eduardo Ferreira</title>
    <link>https://carloseduardodev.com/blog</link>
    <description>Textos sobre o que aprendo construindo software: decisões de arquitetura, integrações, automação e os erros que me ensinaram algo.</description>
    <language>pt-BR</language>
    <managingEditor>contatocarloseduardofe@gmail.com (Carlos Eduardo Ferreira)</managingEditor>
    <lastBuildDate>Tue, 06 Oct 2026 12:00:00 GMT</lastBuildDate>
    <atom:link href="https://carloseduardodev.com/rss.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>Status HTTP explicados pela cara que cada um faz</title>
      <link>https://carloseduardodev.com/blog/status-http-com-a-cara-que-cada-um-faz</link>
      <guid isPermaLink="true">https://carloseduardodev.com/blog/status-http-com-a-cara-que-cada-um-faz</guid>
      <pubDate>Mon, 05 Oct 2026 12:00:00 GMT</pubDate>
      <description>Um tour sem formalidade pelos códigos de status que você encontra de verdade no dia a dia: o que cada um está tentando dizer e qual a reação apropriada ao ver cada um deles.</description>
      <category>HTTP</category>
      <category>APIs</category>
      <category>Básico</category>
      <content:encoded><![CDATA[<link rel="preload" as="image" href="https://carloseduardodev.com/content/blog/status-http-com-a-cara-que-cada-um-faz/200.svg"/><link rel="preload" as="image" href="https://carloseduardodev.com/content/blog/status-http-com-a-cara-que-cada-um-faz/301.svg"/><link rel="preload" as="image" href="https://carloseduardodev.com/content/blog/status-http-com-a-cara-que-cada-um-faz/404.svg"/><link rel="preload" as="image" href="https://carloseduardodev.com/content/blog/status-http-com-a-cara-que-cada-um-faz/418.svg"/><link rel="preload" as="image" href="https://carloseduardodev.com/content/blog/status-http-com-a-cara-que-cada-um-faz/500.svg"/><p>Todo mundo decora o 200 e o 404. Depois disso a conversa fica nebulosa, e aí
alguém devolve <code>200 OK</code> com <code>{&quot;erro&quot;: &quot;deu ruim&quot;}</code> no corpo e o cliente da API
passa a vida achando que está tudo bem.</p>
<p>Então vamos combinar: cada código é uma frase curta que o servidor está falando.
Se você souber a frase, nunca mais escolhe errado.</p>
<h2 id="a-regra-do-primeiro-dígito">A regra do primeiro dígito<a href="#a-regra-do-primeiro-dígito" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<p>O primeiro número já conta a história quase toda:</p>
<table><thead><tr><th>Família</th><th>Tradução livre</th><th>De quem é a culpa</th></tr></thead><tbody><tr><td><code>1xx</code></td><td>&quot;calma, ainda estou processando&quot;</td><td>de ninguém</td></tr><tr><td><code>2xx</code></td><td>&quot;deu certo&quot;</td><td>de ninguém</td></tr><tr><td><code>3xx</code></td><td>&quot;procura em outro lugar&quot;</td><td>de ninguém</td></tr><tr><td><code>4xx</code></td><td>&quot;você errou&quot;</td><td>sua</td></tr><tr><td><code>5xx</code></td><td>&quot;eu errei&quot;</td><td>minha</td></tr></tbody></table>
<p>Essa divisão entre <code>4xx</code> e <code>5xx</code> é a parte que importa mais. Ela decide quem vai
ser acordado às três da manhã.</p>
<h2 id="200-ok">200 OK<a href="#200-ok" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<figure><img src="https://carloseduardodev.com/content/blog/status-http-com-a-cara-que-cada-um-faz/200.svg" alt="Ilustração do status 200 OK"/><figcaption>O código que ninguém comemora, porque é o esperado.</figcaption></figure>
<p>Funcionou. Não tem muito o que falar.</p>
<p>O problema do <code>200</code> é o abuso: ele virou o padrão de quem não quis pensar. Se a
requisição falhou, devolver <code>200</code> com uma mensagem de erro no JSON não está
&quot;simplificando o cliente&quot;: está obrigando todo mundo a abrir o corpo da resposta
para descobrir o que aconteceu. E algum cliente vai esquecer.</p>
<blockquote><p><strong>Dois irmãos que resolvem a sua vida</strong></p><p>Use <code>201 Created</code> quando a requisição criou um recurso, e devolva o <code>Location</code>
apontando para ele. Use <code>204 No Content</code> quando deu certo e não há nada para
mandar de volta, como um <code>DELETE</code>. Seu front agradece.</p></blockquote>
<h2 id="301-moved-permanently">301 Moved Permanently<a href="#301-moved-permanently" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<figure><img src="https://carloseduardodev.com/content/blog/status-http-com-a-cara-que-cada-um-faz/301.svg" alt="Ilustração do status 301 Moved Permanently"/><figcaption>O amigo que mudou de casa e avisou no grupo.</figcaption></figure>
<p>Mudei de endereço e não volto mais. Navegadores e buscadores levam isso a sério:
eles guardam a informação e param de visitar o endereço antigo.</p>
<p>É aí que mora o perigo. Um <code>301</code> feito por engano fica em cache no navegador de
quem visitou, e você não tem como pedir de volta. Quando estiver testando
redirecionamento, use <code>302</code>, ou melhor, <code>307</code>, que preserva o método da
requisição. Quando tiver certeza absoluta, troque para <code>301</code>.</p>
<h2 id="404-not-found">404 Not Found<a href="#404-not-found" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<figure><img src="https://carloseduardodev.com/content/blog/status-http-com-a-cara-que-cada-um-faz/404.svg" alt="Ilustração do status 404 Not Found"/><figcaption>Não existe. E eu não vou te contar se já existiu.</figcaption></figure>
<p>O famoso. Não encontrei nada nesse endereço.</p>
<p>Tem um detalhe elegante aqui que pouca gente usa de propósito: <code>404</code> é a resposta
certa quando o recurso existe mas a pessoa não deveria nem saber disso. Devolver
<code>403 Forbidden</code> confirma que o recurso está lá, e isso já é informação demais.
GitHub faz exatamente isso com repositórios privados.</p>
<blockquote><p><strong>404 não é 400</strong></p><p><code>404</code> é &quot;esse endereço não leva a nada&quot;. <code>400 Bad Request</code> é &quot;entendi o endereço,
mas o que você mandou está malformado&quot;. Trocar um pelo outro confunde quem está
depurando sua API às duas da manhã.</p></blockquote>
<h2 id="418-im-a-teapot">418 I&#x27;m a teapot<a href="#418-im-a-teapot" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<figure><img src="https://carloseduardodev.com/content/blog/status-http-com-a-cara-que-cada-um-faz/418.svg" alt="Ilustração do status 418 I am a teapot"/><figcaption>Nasceu como piada de 1º de abril e sobreviveu a todas as tentativas de remoção.</figcaption></figure>
<p>Esse veio da RFC 2324, um protocolo de brincadeira para cafeteiras publicado em
primeiro de abril de 1998. A ideia: se você pedir café para um bule de chá, ele
responde <code>418</code>.</p>
<p>Já tentaram remover o código das implementações mais de uma vez. A internet
reagiu tão mal que ele continua lá até hoje. Não use em produção, mas saiba que
existe, porque uma hora você vai ver um numa resposta e vai rir sozinho.</p>
<h2 id="500-internal-server-error">500 Internal Server Error<a href="#500-internal-server-error" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<figure><img src="https://carloseduardodev.com/content/blog/status-http-com-a-cara-que-cada-um-faz/500.svg" alt="Ilustração do status 500 Internal Server Error"/><figcaption>A culpa é minha e eu não vou dizer qual foi.</figcaption></figure>
<p>Algo explodiu do meu lado. É o status mais honesto da lista e o mais inútil para
quem recebe: ele não diz nada além de &quot;falhou aqui dentro&quot;.</p>
<p>Isso é intencional. Vazar o stack trace numa resposta é entregar a estrutura
interna do seu sistema para qualquer um. O lugar da mensagem detalhada é o seu
log, com um identificador de correlação que você devolve para a pessoa:</p>
<pre class="shiki shiki-themes github-light github-dark-dimmed" style="--shiki-light:#24292e;--shiki-dark:#adbac7;--shiki-light-bg:#fff;--shiki-dark-bg:#22272e" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">  &quot;error&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">&quot;internal_error&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">  &quot;requestId&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">&quot;7f3c9a21&quot;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">}</span></span></code></pre>
<p>Aí quem está integrando te manda o <code>requestId</code>, você busca no log e descobre em
dez segundos o que levaria uma hora de ida e volta.</p>
<blockquote><p><strong>O 503 é mais gentil</strong></p><p>Quando a falha é temporária (deploy em andamento, dependência fora do ar,
rate limit de infraestrutura), <code>503 Service Unavailable</code> com o header
<code>Retry-After</code> diz ao cliente exatamente quando tentar de novo. Isso é muito
melhor que deixar ele tentando em loop contra um <code>500</code>.</p></blockquote>
<h2 id="o-resumo-que-eu-colaria-na-parede">O resumo que eu colaria na parede<a href="#o-resumo-que-eu-colaria-na-parede" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<ul>
<li>Deu certo e criou algo? <code>201</code>, com <code>Location</code>.</li>
<li>Deu certo e não tem o que devolver? <code>204</code>.</li>
<li>O cliente mandou porcaria? <code>400</code>.</li>
<li>Não sei quem é você? <code>401</code>. Sei quem é e você não pode? <code>403</code>.</li>
<li>Não quero nem confirmar que isso existe? <code>404</code>.</li>
<li>Explodiu aqui? <code>500</code>. Explodiu aqui mas já volta? <code>503</code> com <code>Retry-After</code>.</li>
</ul>
<p>Nenhuma dessas escolhas é sobre purismo. É sobre a pessoa do outro lado conseguir
descobrir o que fazer sem precisar te chamar no WhatsApp.</p>]]></content:encoded>
    </item>
    <item>
      <title>Por que um portfólio em SPA não serve como blog</title>
      <link>https://carloseduardodev.com/blog/por-que-pre-renderizar-um-portfolio-spa</link>
      <guid isPermaLink="true">https://carloseduardodev.com/blog/por-que-pre-renderizar-um-portfolio-spa</guid>
      <pubDate>Mon, 28 Sep 2026 12:00:00 GMT</pubDate>
      <description>Um site React client-side resolve bem um portfólio, mas quebra no momento em que você quer que seus textos sejam encontrados e compartilhados. Como resolvi isso sem reescrever tudo.</description>
      <category>React</category>
      <category>SEO</category>
      <category>Vite</category>
      <content:encoded><![CDATA[<p>Meu portfólio era uma SPA em React com Vite. Para mostrar projetos, isso funciona
perfeitamente: quem chega pelo link vê a página montar em milissegundos e navega
sem recarregar nada.</p>
<p>Quando decidi transformá-lo em blog, o modelo deixou de servir, e o motivo não é
performance.</p>
<h2 id="o-problema-não-é-o-google-é-o-compartilhamento">O problema não é o Google, é o compartilhamento<a href="#o-problema-não-é-o-google-é-o-compartilhamento" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<p>A primeira reação de todo mundo é &quot;mas o Google executa JavaScript&quot;. Executa
mesmo. O problema está em outro lugar.</p>
<p>As meta tags de um SPA vivem em um único <code>index.html</code>:</p>
<pre class="shiki shiki-themes github-light github-dark-dimmed" style="--shiki-light:#24292e;--shiki-dark:#adbac7;--shiki-light-bg:#fff;--shiki-dark-bg:#22272e" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">&lt;</span><span style="--shiki-light:#22863A;--shiki-dark:#8DDB8C">meta</span><span style="--shiki-light:#6F42C1;--shiki-dark:#6CB6FF"> property</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">=</span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">&quot;og:title&quot;</span><span style="--shiki-light:#6F42C1;--shiki-dark:#6CB6FF"> content</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">=</span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">&quot;Carlos Eduardo Ferreira | Software Engineer&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7"> /&gt;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">&lt;</span><span style="--shiki-light:#22863A;--shiki-dark:#8DDB8C">meta</span><span style="--shiki-light:#6F42C1;--shiki-dark:#6CB6FF"> property</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">=</span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">&quot;og:description&quot;</span><span style="--shiki-light:#6F42C1;--shiki-dark:#6CB6FF"> content</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">=</span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">&quot;Software Engineer no Rio de Janeiro...&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7"> /&gt;</span></span></code></pre>
<p>Esse arquivo é o mesmo para todas as rotas. Então, quando alguém compartilha
<code>/blog/meu-artigo</code> no LinkedIn, no X ou no WhatsApp, o crawler lê o HTML servido
(nunca o resultado depois do React rodar) e mostra o preview genérico do
portfólio. Todo artigo fica com o mesmo título e a mesma descrição.</p>
<p>Atualizar <code>document.title</code> em um efeito não resolve:</p>
<pre class="shiki shiki-themes github-light github-dark-dimmed" style="--shiki-light:#24292e;--shiki-dark:#adbac7;--shiki-light-bg:#fff;--shiki-dark-bg:#22272e" tabindex="0"><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#DCBDFB">useEffect</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">(() </span><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">=&gt;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7"> {</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">  document.title </span><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">=</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7"> post.title; </span><span style="--shiki-light:#6A737D;--shiki-dark:#768390">// o crawler social nunca chega aqui</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">}, [post.title]);</span></span></code></pre>
<p>Crawlers de rede social quase nunca executam JavaScript. Para eles, seu blog
inteiro é uma página só.</p>
<h2 id="três-caminhos">Três caminhos<a href="#três-caminhos" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<p>Avaliei três opções:</p>
<table><thead><tr><th>Caminho</th><th>SEO</th><th>Esforço</th><th>O que acontece com o código atual</th></tr></thead><tbody><tr><td>Continuar client-side</td><td>Fraco</td><td>Nenhum</td><td>Nada muda</td></tr><tr><td>Migrar para Astro</td><td>Ótimo</td><td>Alto</td><td>Shell e providers reescritos</td></tr><tr><td>Vite + pré-render no build</td><td>Ótimo</td><td>Médio</td><td>Preservado</td></tr></tbody></table>
<p>Astro é a escolha canônica para sites de conteúdo, e eu recomendaria para quem
começa do zero. No meu caso havia um shell que eu gostava, com sidebar colapsável,
background reativo ao mouse e animações, e reescrevê-lo como layout Astro era
jogar trabalho fora para resolver um problema de meta tags.</p>
<h2 id="o-que-eu-fiz">O que eu fiz<a href="#o-que-eu-fiz" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<p>Mantive a SPA e adicionei um passo depois do build. O <code>npm run build</code> passou a ter
três etapas:</p>
<pre class="shiki shiki-themes github-light github-dark-dimmed" style="--shiki-light:#24292e;--shiki-dark:#adbac7;--shiki-light-bg:#fff;--shiki-dark-bg:#22272e" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">  &quot;build&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">&quot;npm run build:client &amp;&amp; npm run build:server &amp;&amp; npm run prerender&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">  &quot;build:client&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">&quot;vite build&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">  &quot;build:server&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">&quot;vite build --ssr&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">  &quot;prerender&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">&quot;node scripts/prerender.mjs&quot;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">}</span></span></code></pre>
<p>A segunda etapa compila a mesma aplicação para rodar em Node. A terceira percorre
cada rota conhecida, renderiza com <code>renderToString</code>, injeta as meta tags daquela
rota e grava um arquivo HTML de verdade:</p>
<pre class="shiki shiki-themes github-light github-dark-dimmed" style="--shiki-light:#24292e;--shiki-dark:#adbac7;--shiki-light-bg:#fff;--shiki-dark-bg:#22272e" tabindex="0"><code><span class="line"><span>dist/public/</span></span>
<span class="line"><span>├── index.html</span></span>
<span class="line"><span>├── blog/index.html</span></span>
<span class="line"><span>├── blog/meu-artigo/index.html</span></span>
<span class="line"><span>├── til/index.html</span></span>
<span class="line"><span>├── rss.xml</span></span>
<span class="line"><span>└── sitemap.xml</span></span></code></pre>
<p>O resultado: cada artigo tem título, descrição, <code>og:image</code> e JSON-LD próprios no
HTML servido. A SPA continua existindo: ela hidrata por cima do HTML
pré-renderizado e a navegação interna segue instantânea.</p>
<h2 id="o-detalhe-que-quase-me-pegou">O detalhe que quase me pegou<a href="#o-detalhe-que-quase-me-pegou" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<p>O corpo dos artigos é carregado sob demanda, em um chunk separado. Isso é ótimo
para o tamanho do bundle e terrível para hidratação: o servidor renderiza o texto
completo, e o cliente, no primeiro render, ainda não tem esse módulo em memória.
React reclama da divergência e descarta o HTML.</p>
<p>A solução foi esperar o módulo antes de hidratar:</p>
<pre class="shiki shiki-themes github-light github-dark-dimmed" style="--shiki-light:#24292e;--shiki-dark:#adbac7;--shiki-light-bg:#fff;--shiki-dark-bg:#22272e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">async</span><span style="--shiki-light:#D73A49;--shiki-dark:#F47067"> function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#DCBDFB"> start</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">() {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">  await</span><span style="--shiki-light:#6F42C1;--shiki-dark:#DCBDFB"> preloadBodyForPath</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">(window.location.pathname);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">  if</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7"> (container.firstElementChild) </span><span style="--shiki-light:#6F42C1;--shiki-dark:#DCBDFB">hydrateRoot</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">(container, &lt;</span><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">App</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7"> /&gt;);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">  else</span><span style="--shiki-light:#6F42C1;--shiki-dark:#DCBDFB"> createRoot</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">(container).</span><span style="--shiki-light:#6F42C1;--shiki-dark:#DCBDFB">render</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">(&lt;</span><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">App</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7"> /&gt;);</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">}</span></span></code></pre>
<p>Como o HTML pré-renderizado já está visível na tela, essa espera é invisível para
quem lê.</p>
<h2 id="o-que-eu-levaria-para-o-próximo-projeto">O que eu levaria para o próximo projeto<a href="#o-que-eu-levaria-para-o-próximo-projeto" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<p>Se o site vai ter conteúdo desde o começo, use um framework que gere HTML
estático e pare de pensar nisso. Se o conteúdo chega depois, como no meu caso,
pré-renderizar rotas conhecidas é um caminho curto e honesto, e você não precisa
abandonar nada do que já construiu.</p>]]></content:encoded>
    </item>
    <item>
      <title>Mostrando estatísticas de repositórios privados sem vazar dados</title>
      <link>https://carloseduardodev.com/blog/estatisticas-privadas-do-github-sem-vazar-dados</link>
      <guid isPermaLink="true">https://carloseduardodev.com/blog/estatisticas-privadas-do-github-sem-vazar-dados</guid>
      <pubDate>Sun, 20 Sep 2026 12:00:00 GMT</pubDate>
      <description>Como exibo linguagens e contribuições de repos privados no portfólio sem publicar nome de projeto, sem expor token no navegador e sem depender de serviço de terceiro.</description>
      <category>GitHub Actions</category>
      <category>GraphQL</category>
      <category>Privacidade</category>
      <content:encoded><![CDATA[<p>A maior parte do que eu escrevo em código está em repositórios privados de
clientes. Um gráfico de linguagens que só conta repositórios públicos não diz
quase nada sobre o que eu realmente faço no dia a dia.</p>
<p>Eu queria contar os privados. Sem expor nada deles.</p>
<h2 id="as-três-regras-que-me-impus">As três regras que me impus<a href="#as-três-regras-que-me-impus" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<ol>
<li>Nenhum token chega ao navegador.</li>
<li>Nenhum nome, descrição ou URL de repositório privado é publicado.</li>
<li>Nenhum serviço de terceiro recebe meu token.</li>
</ol>
<p>A terceira regra elimina os geradores de badge prontos. As duas primeiras
eliminam qualquer chamada à API feita pelo cliente.</p>
<h2 id="a-forma-do-dado-importa-mais-que-o-acesso">A forma do dado importa mais que o acesso<a href="#a-forma-do-dado-importa-mais-que-o-acesso" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<p>A decisão que resolve o problema não é sobre autenticação, é sobre <strong>o que fica
salvo</strong>. Em vez de guardar uma lista de repositórios, eu guardo só o agregado:</p>
<pre class="shiki shiki-themes github-light github-dark-dimmed" style="--shiki-light:#24292e;--shiki-dark:#adbac7;--shiki-light-bg:#fff;--shiki-dark-bg:#22272e" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">  &quot;summary&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: { </span><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">&quot;repositoryCount&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF">32</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">&quot;stars&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF">1</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7"> },</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">  &quot;activity&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: { </span><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">&quot;commits&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF">94</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">&quot;pullRequests&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF">6</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">&quot;issues&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF">0</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">&quot;reviews&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF">0</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7"> },</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">  &quot;languages&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: [</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">    { </span><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">&quot;name&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">&quot;TypeScript&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">&quot;color&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">&quot;#3178c6&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">&quot;bytes&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF">141815</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#8DDB8C">&quot;percentage&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF">62.71</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7"> }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">  ]</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">}</span></span></code></pre>
<p>Um repositório privado contribui com bytes para o total de uma linguagem e com
<code>+1</code> na contagem. Ele não aparece em lugar nenhum de forma identificável: nem o
nome, nem quando foi atualizado, nem quantas estrelas tem. O número agregado não
é reversível.</p>
<h2 id="onde-o-token-vive">Onde o token vive<a href="#onde-o-token-vive" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<p>A coleta roda em um GitHub Action diário, nunca no navegador:</p>
<pre class="shiki shiki-themes github-light github-dark-dimmed" style="--shiki-light:#24292e;--shiki-dark:#adbac7;--shiki-light-bg:#fff;--shiki-dark-bg:#22272e" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">- </span><span style="--shiki-light:#22863A;--shiki-dark:#8DDB8C">name</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">Generate GitHub statistics</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#8DDB8C">  run</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">node scripts/update-github-stats.mjs</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#8DDB8C">  env</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">:</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#8DDB8C">    GITHUB_STATS_TOKEN</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">${{ secrets.GH_STATS_TOKEN || github.token }}</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#8DDB8C">    INCLUDE_PRIVATE</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">: </span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">${{ secrets.GH_STATS_TOKEN != &#x27;&#x27; }}</span></span></code></pre>
<p>Duas coisas acontecem aqui que vale destacar.</p>
<p>A primeira é o fallback: se o secret <code>GH_STATS_TOKEN</code> não existe, o workflow usa o
<code>GITHUB_TOKEN</code> temporário do Actions e produz apenas dados públicos. O site nunca
quebra por falta de configuração; ele só fica menos completo.</p>
<p>A segunda é que <code>INCLUDE_PRIVATE</code> é derivado da existência do secret, não escrito
à mão. Não existe estado em que eu peça dados privados sem ter credencial para
isso.</p>
<h2 id="uma-verificação-que-vale-o-esforço">Uma verificação que vale o esforço<a href="#uma-verificação-que-vale-o-esforço" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<p>A query GraphQL pede <code>viewer { login }</code> junto com os dados. Com isso o script
confere se o token pertence mesmo ao usuário configurado:</p>
<pre class="shiki shiki-themes github-light github-dark-dimmed" style="--shiki-light:#24292e;--shiki-dark:#adbac7;--shiki-light-bg:#fff;--shiki-dark-bg:#22272e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">if</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7"> (includePrivate </span><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">&amp;&amp;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7"> data.viewer.login.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#DCBDFB">toLowerCase</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">() </span><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">!==</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7"> username.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#DCBDFB">toLowerCase</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">()) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">  throw</span><span style="--shiki-light:#D73A49;--shiki-dark:#F47067"> new</span><span style="--shiki-light:#6F42C1;--shiki-dark:#DCBDFB"> Error</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">(</span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">&quot;GH_STATS_TOKEN não pertence ao usuário configurado.&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">);</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">}</span></span></code></pre>
<p>Parece paranoia até você trocar um token e passar a publicar silenciosamente a
atividade de outra conta. Falhar o build é muito melhor que acertar por acidente.</p>
<h2 id="o-que-o-navegador-recebe">O que o navegador recebe<a href="#o-que-o-navegador-recebe" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<p>Um arquivo JSON estático, commitado no repositório e servido junto com o site.
O cliente faz um <code>fetch</code> e, se algo der errado, cai em um fallback compilado no
bundle:</p>
<pre class="shiki shiki-themes github-light github-dark-dimmed" style="--shiki-light:#24292e;--shiki-dark:#adbac7;--shiki-light-bg:#fff;--shiki-dark-bg:#22272e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">export</span><span style="--shiki-light:#D73A49;--shiki-dark:#F47067"> function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#DCBDFB"> useGitHubStats</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">() {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">  const</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7"> [</span><span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF">stats</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF">setStats</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">] </span><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">=</span><span style="--shiki-light:#6F42C1;--shiki-dark:#DCBDFB"> useState</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">&lt;</span><span style="--shiki-light:#6F42C1;--shiki-dark:#F69D50">GitHubStats</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">&gt;(fallbackGitHubStats);</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#768390">  // ...</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">}</span></span></code></pre>
<p>Nenhuma chave, nenhuma chamada autenticada, nenhuma dependência de terceiro em
tempo de execução. A página carrega igual se a API do GitHub estiver fora do ar.</p>
<h2 id="o-custo-disso">O custo disso<a href="#o-custo-disso" class="heading-anchor" tabindex="-1" aria-hidden="true"><span>#</span></a></h2>
<p>O dado tem até 24 horas de atraso. Para um portfólio, isso é irrelevante, e é um
preço pequeno por não ter segredo nenhum rodando no navegador de quem visita.</p>]]></content:encoded>
    </item>
  </channel>
</rss>
