<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Gabriel Abreu</title>
    <description>The latest articles on DEV Community by Gabriel Abreu (@gabbs279).</description>
    <link>https://dev.to/gabbs279</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4088779%2F3f19a571-6ba6-4964-a022-bc33296091c2.jpg</url>
      <title>DEV Community: Gabriel Abreu</title>
      <link>https://dev.to/gabbs279</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gabbs279"/>
    <language>en</language>
    <item>
      <title>El punto ciego de la auditoría: pesé el build, no la página</title>
      <dc:creator>Gabriel Abreu</dc:creator>
      <pubDate>Fri, 28 Aug 2026 01:56:25 +0000</pubDate>
      <link>https://dev.to/gabbs279/el-punto-ciego-de-la-auditoria-pese-el-build-no-la-pagina-1hch</link>
      <guid>https://dev.to/gabbs279/el-punto-ciego-de-la-auditoria-pese-el-build-no-la-pagina-1hch</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhu6tgyd2tvlovim9n4km.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhu6tgyd2tvlovim9n4km.png" alt="El punto ciego de la auditoría: pesé el build, no la página" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Publiqué un post que se llamaba "Le hice una auditoría a mi propio portfolio y encontré 20 problemas". Era un inventario: recorrí mi propio sitio —una SPA de React 19 + Vite con Sanity como CMS—, anoté todo lo que estaba mal, arreglé lo que importaba y puse los números de antes y después al lado de cada punto. Si no lo has leído, la única parte que importa aquí es la metodología, y una línea de ella en particular:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Analicé el tamaño del build, chunk por chunk, en &lt;code&gt;build/assets/&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Lo llamé el paso que más duele y el que más gente se salta. Sigo pensando que es verdad. También es el paso que garantizó que se me escapara el problema más grande que tenía el sitio.&lt;/p&gt;

&lt;h2&gt;
  
  
  El paso que funcionó
&lt;/h2&gt;

&lt;p&gt;Pesar la salida del build funcionó exactamente como prometía. El hallazgo 1 de esa auditoría fue un PNG sin optimizar de una ilustración de desarrollador en &lt;code&gt;/gabriel-abreu&lt;/code&gt;, mi página de contacto, 993 KB, servidos a cada visitante que caía ahí. Quedó en 23 KB. Una segunda imagen, el recorte mío que aparece en las tres variantes del componente &lt;code&gt;Greeting&lt;/code&gt;, pasó de 358 KB a 45 KB.&lt;/p&gt;

&lt;p&gt;Esas dos son assets empaquetados. Un componente importa una:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../assets/developer-illustration.webp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vite sigue esa importación, le pone un hash al archivo y lo emite en &lt;code&gt;build/assets/&lt;/code&gt;. Después del build es un archivo en disco con un tamaño. Listar el directorio lo encuentra. Ordenar el listado por tamaño lo encuentra de primero. No hay forma de servirlo sin que aparezca en ese paso.&lt;/p&gt;

&lt;p&gt;Así que el método era sólido dentro de su dominio: esas dos imágenes son assets empaquetados, y el paso encontró las dos.&lt;/p&gt;

&lt;p&gt;El 23 de agosto abrí el índice del blog en un navegador y miré lo que pedía de verdad. Dieciséis portadas de posts, 9.88 MB.&lt;/p&gt;

&lt;p&gt;Nada de eso podía haber aparecido en la auditoría. No porque ese día estuviera descuidado, sino por el lugar del que vienen esos bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dos ciclos de vida
&lt;/h2&gt;

&lt;p&gt;Un asset empaquetado existe en tiempo de build. Una importación lo convierte en entrada del build, el bundler lo convierte en salida del build, y cualquier cosa que lea la salida del build lo ve.&lt;/p&gt;

&lt;p&gt;Una imagen del CMS nunca es una entrada del build. Nadie la importa. Llega como un string en el resultado de una consulta, después de que la página ya cargó. Esta es la consulta que alimenta el índice del blog:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"post"&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;order&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;publishedAt&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="ss"&gt;){&lt;/span&gt;
  &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;mainImage&lt;/span&gt;&lt;span class="ss"&gt;{&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;{&lt;/span&gt; &lt;span class="n"&gt;_id&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="ss"&gt;}&lt;/span&gt; &lt;span class="ss"&gt;},&lt;/span&gt;
  &lt;span class="n"&gt;publishedAt&lt;/span&gt;
&lt;span class="ss"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eso corre en un &lt;code&gt;useEffect&lt;/code&gt; después del montaje. La &lt;code&gt;url&lt;/code&gt; que regresa apunta a &lt;code&gt;cdn.sanity.io&lt;/code&gt;, React la mete en un &lt;code&gt;img src&lt;/code&gt;, y el navegador la baja de un CDN que yo no construyo. En ningún momento del ciclo de vida de esa imagen aterriza un archivo en &lt;code&gt;build/assets/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Dos ciclos de vida distintos, y el paso más riguroso de mi auditoría solo podía observar uno de ellos. No "no lo observó de casualidad". No podía, por diseño. La balanza estaba bien. No estaba pesando toda la carga.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que de verdad había en la página
&lt;/h2&gt;

&lt;p&gt;Cuando medí con el Accept header real de un navegador en vez de con un listado de directorio:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/allpost, 16 post covers ........ 9.88 MB
one post page, 4 images ......... 4.54 MB
peor imagen suelta .............. 3.13 MB (PNG 2160x2700, mostrada a ~250px)
another cover ................... 2.27 MB (rendered at 433x227)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Las dos imágenes que atrapó la auditoría sumaban 1,351 KB entre las dos. Una sola ruta estaba sirviendo 9.88 MB.&lt;/p&gt;

&lt;p&gt;Todos mis cross-posts en dev.to cierran con un enlace a &lt;code&gt;/allpost&lt;/code&gt;. La ruta más pesada que medí era la página a la que mando a la gente.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;urlFor&lt;/code&gt; se llamaba una sola vez
&lt;/h2&gt;

&lt;p&gt;Sanity trae un builder de URLs de imagen, &lt;code&gt;urlFor&lt;/code&gt;, que aplica transformaciones en el CDN: ancho, fit, formato. Antes del arreglo, &lt;code&gt;urlFor&lt;/code&gt; se llamaba exactamente una vez en toda la aplicación. No una vez por componente. Una. Estaba en &lt;code&gt;OnePost.tsx&lt;/code&gt;, dentro del serializador de PortableText para las imágenes incrustadas en el cuerpo de un artículo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;asset&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;urlFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;width&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1600&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;format&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ancho topado, &lt;code&gt;fit: max&lt;/code&gt; para que nada se escale hacia arriba, &lt;code&gt;auto: format&lt;/code&gt; para que los navegadores modernos reciban WebP. Esa es la llamada correcta. Vale para las imágenes que un autor soltó en medio de un párrafo, las que un lector casi ni nota.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AllPosts.tsx&lt;/code&gt; no tenía ni una sola referencia a él. El encabezado del post tampoco. Esas tarjetas renderizaban &lt;code&gt;mainImage.asset.url&lt;/code&gt; —el archivo original subido, directo del CDN— y dejaban que el CSS lo encogiera dentro de una caja de 250px. El navegador se baja los 3.13 MB completos primero y después lo pinta pequeño.&lt;/p&gt;

&lt;p&gt;El sitio se veía como uno que servía sus imágenes del tamaño correcto. No lo era.&lt;/p&gt;

&lt;h2&gt;
  
  
  El arreglo
&lt;/h2&gt;

&lt;p&gt;Las consultas que alimentan esas tarjetas proyectan &lt;code&gt;asset-&amp;gt;{url}&lt;/code&gt;, no el objeto de referencia que &lt;code&gt;urlFor&lt;/code&gt; quiere, así que pasar por el constructor significaba reescribir las consultas. En vez de eso, el helper toma la URL que la consulta ya devuelve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sizedImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Non-Sanity URLs (the BlockNote editor stores upload-endpoint URLs directly)&lt;/span&gt;
  &lt;span class="c1"&gt;// do not understand these parameters.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cdn.sanity.io&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;sep&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;w=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;fit=max&amp;amp;auto=format&amp;amp;q=75`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;auto=format&lt;/code&gt; negocia según el Accept header de la petición, así que un navegador moderno recibe WebP y uno viejo recibe el formato original. &lt;code&gt;fit=max&lt;/code&gt; solo escala hacia abajo, así que un original pequeño nunca se agranda. El chequeo de &lt;code&gt;cdn.sanity.io&lt;/code&gt; está ahí porque mi editor BlockNote guarda directamente URLs del endpoint de subida y esas no entienden los parámetros: agregárselos habría roto imágenes que estaban funcionando.&lt;/p&gt;

&lt;p&gt;Medidas otra vez de la misma manera, esas dieciséis portadas sumaron 182 KB, 56 veces menos que antes. El PNG de 2160x2700 pesa 24 KB.&lt;/p&gt;

&lt;p&gt;Ese es el número de esas imágenes en esas rutas, medido con un navegador y en un solo día. No he medido el tiempo de carga en una conexión real, y no te voy a decir que ahora el sitio es rápido.&lt;/p&gt;

&lt;h2&gt;
  
  
  El alcance es la afirmación que no audité
&lt;/h2&gt;

&lt;p&gt;Una auditoría contesta la pregunta que le hagas, y nada más. La mía preguntaba: ¿qué pesa mucho dentro de lo que sirvo? La respuesta era correcta.&lt;/p&gt;

&lt;p&gt;Pero "lo que sirvo" es una frontera que dibuja el bundler, y el navegador no sabe que esa frontera existe. Pide lo que la página le dice que pida, venga de donde venga. Mi método trataba un artefacto de build como sustituto de una carga de página, y en un sitio como este esas dos cosas se traslapan sin ser el mismo conjunto.&lt;/p&gt;

&lt;p&gt;Lo que hacía que ese paso se sintiera riguroso es lo mismo que lo hacía estrecho: era mecánico. Un directorio, una lista de archivos, tamaños, ordenados. Una medición que puede ser exhaustiva es exhaustiva sobre su propio dominio y muda sobre todo lo demás, y no parece incompleta: parece terminada. Veinte hallazgos, todos reales, y 9.88 MB saliendo todavía por la ruta que anuncio.&lt;/p&gt;

&lt;p&gt;La lista de hallazgos es la parte de una auditoría que sí se revisa. El alcance también es una afirmación —una afirmación sobre dónde pueden estar los problemas— y es la que sale sin examinar. La próxima vez voy a escribir esa afirmación al lado del método, en el mismo documento: esto fue lo que medí, y esto es lo que esta medición no puede ver.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Verde y ciego: cuando un check que pasa no significa nada</title>
      <dc:creator>Gabriel Abreu</dc:creator>
      <pubDate>Fri, 28 Aug 2026 01:56:22 +0000</pubDate>
      <link>https://dev.to/gabbs279/verde-y-ciego-cuando-un-check-que-pasa-no-significa-nada-21nh</link>
      <guid>https://dev.to/gabbs279/verde-y-ciego-cuando-un-check-que-pasa-no-significa-nada-21nh</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F979daxk2i4jmteaqict3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F979daxk2i4jmteaqict3.png" alt="Verde y ciego: cuando un check que pasa no significa nada" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Escribí un mensaje de commit que dice que &lt;code&gt;SEO.tsx&lt;/code&gt; "pone documentElement.lang" y "emite enlaces hreflang". Abrí un post en español en el navegador, miré el documento, y ahí estaba: &lt;code&gt;documentElement.lang === "es"&lt;/code&gt;, y tres etiquetas &lt;code&gt;&amp;lt;link rel="alternate" hreflang&amp;gt;&lt;/code&gt; para &lt;code&gt;es&lt;/code&gt;, &lt;code&gt;en&lt;/code&gt; y &lt;code&gt;x-default&lt;/code&gt;. Idioma correcto, pares correctos, default correcto.&lt;/p&gt;

&lt;p&gt;El mensaje de commit era exacto. El sitio también estaba sirviendo &lt;code&gt;&amp;lt;html lang="en"&amp;gt;&lt;/code&gt; en cada post en español, con cero anotaciones hreflang.&lt;/p&gt;

&lt;p&gt;Las dos frases son ciertas. Pero cada una habla de una superficie distinta.&lt;/p&gt;

&lt;h2&gt;
  
  
  La anotación que estaba y no estaba
&lt;/h2&gt;

&lt;p&gt;Este sitio es una SPA de React 19 en Vercel con un paso de prerender en tiempo de build. El prerender escribe el &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; dentro del HTML estático para que los crawlers reciban títulos y meta tags sin ejecutar nada. &lt;code&gt;SEO.tsx&lt;/code&gt; es un componente de React. Corre en el navegador, después de que React monta, y hace exactamente lo que dice su mensaje de commit.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scripts/prerender.mjs&lt;/code&gt; escribe el HTML que el servidor manda de verdad. En ese commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;git show 86569b0:scripts/prerender.mjs | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; hreflang
&lt;span class="go"&gt;0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El prerender reescribía solo el elemento &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;. Nunca tocaba la etiqueta &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;, así que el atributo &lt;code&gt;lang&lt;/code&gt; se quedaba con lo que &lt;code&gt;index.html&lt;/code&gt; traía de fábrica, que era &lt;code&gt;en&lt;/code&gt;. Y no emitía ningún alternate, porque nada dentro de él sabía que la tabla de traducciones existía.&lt;/p&gt;

&lt;p&gt;Lo confirmé con &lt;code&gt;curl&lt;/code&gt; en las cuatro URLs emparejadas: original en inglés y traducción en español, en las dos direcciones. Después probé otra vez con un user-agent de Googlebot, por si Vercel estaba haciendo alguna jugada con los crawlers. Respuesta idéntica byte a byte. No había una segunda vía de entrega. El sitemap tampoco tenía alternates &lt;code&gt;xhtml:link&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Aquí hay una trampa que vale la pena nombrar, porque es lo que deja que el error sobreviva un rato. Cada página del sitio sirve esto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"application/rss+xml"&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le haces grep a &lt;code&gt;alternate&lt;/code&gt; sobre el HTML servido y encuentras una coincidencia. Si estás revisando rápido, y ya estás bastante seguro de que la funcionalidad jala porque la viste funcionar, una coincidencia es suficiente. La cadena que buscaste estaba presente. Era el feed.&lt;/p&gt;

&lt;p&gt;La consecuencia práctica es más limitada de lo que suena. Google renderiza JavaScript, así que para Google esto fue un retraso y no una pérdida. El riesgo estaba en los motores que no renderizan, que veían posts en español declarados como inglés y sin ninguna indicación de que existiera una traducción.&lt;/p&gt;

&lt;p&gt;Arreglado el 21 de agosto. &lt;code&gt;prerender.mjs&lt;/code&gt; ahora pone &lt;code&gt;lang&lt;/code&gt; en el elemento &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; y emite los alternates, leyendo &lt;code&gt;src/config/translations.json&lt;/code&gt;, el mismo archivo que lee &lt;code&gt;src/config/translations.ts&lt;/code&gt;. La tabla vive en JSON porque un script de build en &lt;code&gt;.mjs&lt;/code&gt; no puede importar un módulo &lt;code&gt;.ts&lt;/code&gt;, y tener dos copias de ella es precisamente la forma en que las dos mitades se volverían a separar.&lt;/p&gt;

&lt;p&gt;Verificado después: &lt;code&gt;lang="es"&lt;/code&gt; en los dos posts en español, &lt;code&gt;lang="en"&lt;/code&gt; en los dos en inglés, tres enlaces hreflang en cada uno de los cuatro posts emparejados, y cero en un post sin pareja como &lt;code&gt;/tailwind-css&lt;/code&gt;, que es el número correcto, porque un hreflang que apunta a una traducción que no existe es una afirmación falsa.&lt;/p&gt;

&lt;p&gt;Aquí no verifiqué lo equivocado. Verifiqué algo real en la superficie que era más fácil de alcanzar.&lt;/p&gt;

&lt;h2&gt;
  
  
  El slot que ningún test estaba mirando
&lt;/h2&gt;

&lt;p&gt;El segundo no se trata de revisar la superficie equivocada. No había superficie que revisar.&lt;/p&gt;

&lt;p&gt;Cada post lleva dos unidades de anuncio: una dentro del artículo y otra al pie del post. El 20 de agosto a las 22:48 subí esta línea:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-ad-status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-response&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres segundos después del montaje, si AdSense no había escrito un veredicto sobre el elemento, el código se inventaba uno. El CSS colapsaba el contenedor en &lt;code&gt;no-response&lt;/code&gt;, así que el slot pasaba a &lt;code&gt;display: none&lt;/code&gt;, mientras AdSense todavía lo estaba midiendo. No se puede colocar un anuncio dentro de una caja oculta y de ancho cero. La suposición se cumplía sola.&lt;/p&gt;

&lt;p&gt;Eso estuvo en producción unas diez horas. El arreglo, a las 08:46 de la mañana siguiente, quitó el timeout por completo: solo el veredicto propio de AdSense cambia el estado, y un veredicto ausente significa "pending", que reserva espacio en vez de colapsarlo.&lt;/p&gt;

&lt;p&gt;Eso metió el mismo bug, pero al revés.&lt;/p&gt;

&lt;p&gt;En producción, la unidad del final del post llega a &lt;code&gt;data-adsbygoogle-status="done"&lt;/code&gt; y después nunca recibe &lt;code&gt;data-ad-status&lt;/code&gt;. Con la regla nueva, "nunca" es indistinguible de "todavía no". Se quedaba en &lt;code&gt;pending&lt;/code&gt; permanentemente. Medido en un navegador real el 23 de agosto, en /portfolio-audit-20-problems, los dos slots en el mismo momento:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;slot 9344511662 (end of post)
  data-adsbygoogle-status "done"
  data-ad-status null
  iframes 0
  height 303px
  display block
  "Advertisement" label visible

slot 2970675007 (in-article)
  data-adsbygoogle-status "done"
  data-ad-status "unfilled"
  height 0px
  display none
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Misma página, misma carga. La asimetría estaba en mi máquina de estados, no en AdSense.&lt;/p&gt;

&lt;p&gt;Así que durante dos días y medio, cada post de este sitio terminaba con un rectángulo vacío etiquetado "Advertisement", de 303 píxeles de alto en la página que medí. Nada estaba en rojo. Ningún test falló. El build pasó, la página renderizó.&lt;/p&gt;

&lt;p&gt;El arreglo de verdad: el periodo de gracia ahora empieza solo cuando el propio AdSense reporta &lt;code&gt;done&lt;/code&gt;, y el observer se queda escuchando después para que un veredicto tardío igual gane. También lee el atributo una vez, de inmediato, al engancharse, porque un &lt;code&gt;MutationObserver&lt;/code&gt; no te dice nada de lo que pasó antes de que llamaras a &lt;code&gt;observe()&lt;/code&gt;. Verificado después: los dos slots en 0px, &lt;code&gt;display: none&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verde porque nadie pregunta
&lt;/h2&gt;

&lt;p&gt;Los dos fallos no tienen la misma forma.&lt;/p&gt;

&lt;p&gt;En el caso del hreflang había un check, y estaba mirando el DOM del navegador en vez del cuerpo de la respuesta. Superficie equivocada, error honesto, encontrable.&lt;/p&gt;

&lt;p&gt;En el caso del anuncio no había check. No uno débil: ninguno. Y desde afuera, "no hay check" y "el check pasó" producen una señal idéntica: un build que se pone verde. La ausencia de un test que falla se ve exactamente igual que la presencia de uno que pasa cuando lo único que puedes ver es el color. Un assert que falta no se anuncia. Uno roto sí.&lt;/p&gt;

&lt;p&gt;Por eso la caja vacía duró dos días y medio y el bug del hreflang duró hasta que se me ocurrió correr &lt;code&gt;curl&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  El hilo del que salió esto
&lt;/h2&gt;

&lt;p&gt;Escribo esto por un comentario. En el cross-post de dev.to de "How I Actually Code with Claude Code", Heinrich Neb lo dijo mejor de lo que yo puedo: un check puede estar verde y ciego. Su ejemplo era un test que afirmaba que un número de benchmark aparecía en la salida de su servidor, y el número era una cadena escrita a mano. El check vigilaba la frase, no la medición. Se quedó verde durante semanas. Debashish Ghosal, en el mismo hilo, sugirió escribir una nota de resultado en cada archivo de plan después de ejecutarlo, que es otra manera de atacar el mismo problema: que el registro diga lo que pasó, no lo que se pretendía.&lt;/p&gt;

&lt;p&gt;No tengo una solución general. No voy a terminar esto con una filosofía de testing, porque me la estaría inventando en el momento para que el post parezca terminado.&lt;/p&gt;

&lt;p&gt;Lo que sí tengo es una costumbre: abrir un navegador, buscar el elemento específico y medirlo. No la idea que el framework tiene del elemento: el elemento. Hacerle &lt;code&gt;curl&lt;/code&gt; a la URL en vez de confiar en el componente que escribe la etiqueta. Leer la altura de la caja en vez de confiar en el estado que decide la altura.&lt;/p&gt;

&lt;p&gt;Así fue como aparecieron los dos: uno a la vez, a mano, porque se me ocurrió mirar. No habría encontrado un tercero.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>The Audit's Blind Spot: I Weighed the Build, Not the Page</title>
      <dc:creator>Gabriel Abreu</dc:creator>
      <pubDate>Wed, 26 Aug 2026 12:27:09 +0000</pubDate>
      <link>https://dev.to/gabbs279/the-audits-blind-spot-i-weighed-the-build-not-the-page-37p4</link>
      <guid>https://dev.to/gabbs279/the-audits-blind-spot-i-weighed-the-build-not-the-page-37p4</guid>
      <description>&lt;p&gt;I published a post called &lt;a href="https://codewithgabo.com/portfolio-audit-20-problems?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=audit-blind-spot" rel="noopener noreferrer"&gt;"I Audited My Own Portfolio and Found 20 Problems"&lt;/a&gt;. It&lt;br&gt;
was an inventory: I went through my own site — a React 19 + Vite SPA with Sanity&lt;br&gt;
as the CMS — wrote down everything that was wrong with it, fixed what mattered, and put the before and after numbers next to each item. If you haven't read it, the&lt;br&gt;
only part that matters here is the methodology, and one line of it in particular:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I went through the build output chunk by chunk in &lt;code&gt;build/assets/&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I called that the step that hurts and the one most people skip. I still think&lt;br&gt;
that is true. It is also the step that guaranteed I would miss the largest thing&lt;br&gt;
wrong with the site.&lt;/p&gt;
&lt;h2&gt;
  
  
  The step that worked
&lt;/h2&gt;

&lt;p&gt;Weighing the build output worked exactly as advertised. Finding 1 of that audit&lt;br&gt;
was an unoptimized PNG of a developer illustration on &lt;code&gt;/gabriel-abreu&lt;/code&gt;, my contact page, 993 KB, sent&lt;br&gt;
to every visitor who landed there. It went to 23 KB. A second image, the cutout&lt;br&gt;
of me that sits in three different greetings, went from 358 KB to 45 KB.&lt;/p&gt;

&lt;p&gt;Those two are bundled assets. A component imports one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../assets/developer-illustration.webp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vite follows that import, hashes the file, and emits it into &lt;code&gt;build/assets/&lt;/code&gt;.&lt;br&gt;
After the build it is a file on disk with a size. Listing the directory finds it.&lt;br&gt;
Sorting the listing by size finds it first. There is no way to ship it and not&lt;br&gt;
have it show up in that step.&lt;/p&gt;

&lt;p&gt;So the method was sound within its domain: both of those images are bundled assets, and the step found both.&lt;/p&gt;

&lt;p&gt;On August 23 I opened the blog index in a browser and watched what it actually&lt;br&gt;
requested. Sixteen post covers, 9.88 MB.&lt;/p&gt;

&lt;p&gt;None of that could have appeared in the audit. Not because I was sloppy that day&lt;br&gt;
— because of where those bytes come from.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two lifecycles
&lt;/h2&gt;

&lt;p&gt;A bundled asset exists at build time. An import makes it a build input, the&lt;br&gt;
bundler makes it a build output, and anything that reads the build output sees&lt;br&gt;
it.&lt;/p&gt;

&lt;p&gt;A CMS image is never a build input. Nothing imports it. It arrives as a string in&lt;br&gt;
a query result, after the page has already loaded. This is the query that feeds&lt;br&gt;
the blog index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*[_type == "post"] | order(publishedAt desc){
  title,
  slug,
  mainImage{ asset-&amp;gt;{ _id, url } },
  publishedAt
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That runs in a &lt;code&gt;useEffect&lt;/code&gt; after mount. The &lt;code&gt;url&lt;/code&gt; that comes back points at&lt;br&gt;
&lt;code&gt;cdn.sanity.io&lt;/code&gt;, React puts it in an &lt;code&gt;img src&lt;/code&gt;, and the browser fetches it from a&lt;br&gt;
CDN I do not build. At no point in the lifecycle of that image does a file land&lt;br&gt;
in &lt;code&gt;build/assets/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two different lifecycles, and my audit's most rigorous step could only observe&lt;br&gt;
one of them. Not "did not happen to observe." Could not, by construction. The&lt;br&gt;
scale was accurate. It was not weighing the whole load.&lt;/p&gt;
&lt;h2&gt;
  
  
  What was actually on the page
&lt;/h2&gt;

&lt;p&gt;Once I measured with a browser's real Accept header instead of a directory&lt;br&gt;
listing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/allpost, 16 post covers ........ 9.88 MB
one post page, 4 images ......... 4.54 MB
worst single image .............. 3.13 MB  (2160x2700 PNG, rendered ~250px wide)
another cover ................... 2.27 MB  (rendered at 433x227)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two images the audit caught came to 1,351 KB between them. One route was&lt;br&gt;
serving 9.88 MB.&lt;/p&gt;

&lt;p&gt;Every one of my dev.to cross-posts closes with a link to &lt;code&gt;/allpost&lt;/code&gt;. The heaviest route I measured was the page I send people to.&lt;/p&gt;
&lt;h2&gt;
  
  
  &lt;code&gt;urlFor&lt;/code&gt; was called once
&lt;/h2&gt;

&lt;p&gt;Sanity ships an image-URL builder, &lt;code&gt;urlFor&lt;/code&gt;, that applies CDN transformations —&lt;br&gt;
width, fit, format. Before the fix, &lt;code&gt;urlFor&lt;/code&gt; was called exactly once in the application. Not once per component. Once. It was in &lt;code&gt;OnePost.tsx&lt;/code&gt;, inside the&lt;br&gt;
PortableText serializer for images embedded in the body of an article:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;asset&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;urlFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;width&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1600&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;format&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Width capped, &lt;code&gt;fit: max&lt;/code&gt; so nothing upscales, &lt;code&gt;auto: format&lt;/code&gt; so modern browsers&lt;br&gt;
get WebP. That is the correct call. It applies to images an author dropped into&lt;br&gt;
the middle of a paragraph — the images a reader is least likely to notice.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AllPosts.tsx&lt;/code&gt; contained zero references to it. So did the post header. Those&lt;br&gt;
cards rendered &lt;code&gt;mainImage.asset.url&lt;/code&gt; — the original upload, straight from the&lt;br&gt;
CDN — and let CSS scale it into a 250px box. The browser downloads all 3.13 MB first&lt;br&gt;
and then paints it small.&lt;/p&gt;

&lt;p&gt;It made the site look like a site that sized its images.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The queries feeding those cards project &lt;code&gt;asset-&amp;gt;{url}&lt;/code&gt;, not the reference object&lt;br&gt;
&lt;code&gt;urlFor&lt;/code&gt; wants, so going through the builder meant rewriting the queries. Instead&lt;br&gt;
the helper takes the URL the query already returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sizedImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Non-Sanity URLs (the BlockNote editor stores upload-endpoint URLs directly)&lt;/span&gt;
  &lt;span class="c1"&gt;// do not understand these parameters.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cdn.sanity.io&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;sep&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;w=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;fit=max&amp;amp;auto=format&amp;amp;q=75`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;auto=format&lt;/code&gt; negotiates on the request's Accept header, so a modern browser gets&lt;br&gt;
WebP and an older one gets the original format. &lt;code&gt;fit=max&lt;/code&gt; only ever scales down,&lt;br&gt;
so a small source is never blown up. The &lt;code&gt;cdn.sanity.io&lt;/code&gt; check is there because&lt;br&gt;
my BlockNote editor stores upload-endpoint URLs directly and those do not&lt;br&gt;
understand the parameters — appending them would have broken images that were&lt;br&gt;
working.&lt;/p&gt;

&lt;p&gt;Measured the same way afterward, those sixteen covers came to 182 KB, 56 times&lt;br&gt;
less than before. The 2160x2700 PNG is 24 KB.&lt;/p&gt;

&lt;p&gt;That is the number for those images on those routes, measured with one browser on one day. I have not measured load time on a real connection, and I am not&lt;br&gt;
going to tell you the site is fast now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scope is the claim I did not audit
&lt;/h2&gt;

&lt;p&gt;An audit answers the question you point it at. Mine asked: what is heavy in what&lt;br&gt;
I ship? The answer was correct.&lt;/p&gt;

&lt;p&gt;But "what I ship" is a boundary drawn by the bundler, and the browser does not&lt;br&gt;
know that boundary exists. It requests what the page tells it to request, from&lt;br&gt;
wherever. My method treated a build artifact as a stand-in for a page load, and&lt;br&gt;
those two things overlap on a site like this without being the same set.&lt;/p&gt;

&lt;p&gt;What made that step feel rigorous is the same thing that made it narrow: it was&lt;br&gt;
mechanical. A directory, a list of files, sizes, sorted. A measurement that can&lt;br&gt;
be exhaustive is exhaustive over its own domain and silent about everything else,&lt;br&gt;
and it does not feel silent — it feels finished. Twenty findings, all real, and 9.88 MB still going out on the route I advertise.&lt;/p&gt;

&lt;p&gt;The list of findings is the part of an audit that gets checked. The scope is a&lt;br&gt;
claim too — a claim about where problems are allowed to be — and it is the one&lt;br&gt;
that ships unexamined. Next time I will write that claim down next to the&lt;br&gt;
method, in the same document: here is what I measured, and here is what this&lt;br&gt;
measurement cannot see.&lt;/p&gt;

&lt;p&gt;I write up the things I break and fix at &lt;a href="https://codewithgabo.com/allpost?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=audit-blind-spot" rel="noopener noreferrer"&gt;codewithgabo.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>performance</category>
      <category>webdev</category>
      <category>react</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Green and Blind: When a Passing Check Means Nothing</title>
      <dc:creator>Gabriel Abreu</dc:creator>
      <pubDate>Tue, 25 Aug 2026 13:12:59 +0000</pubDate>
      <link>https://dev.to/gabbs279/green-and-blind-when-a-passing-check-means-nothing-174c</link>
      <guid>https://dev.to/gabbs279/green-and-blind-when-a-passing-check-means-nothing-174c</guid>
      <description>&lt;p&gt;I wrote a commit message that says &lt;code&gt;SEO.tsx&lt;/code&gt; "sets documentElement.lang" and "emits hreflang links". I opened a Spanish post in the browser, looked at the document, and there it was: &lt;code&gt;documentElement.lang === "es"&lt;/code&gt;, and three &lt;code&gt;&amp;lt;link rel="alternate" hreflang&amp;gt;&lt;/code&gt; tags for &lt;code&gt;es&lt;/code&gt;, &lt;code&gt;en&lt;/code&gt;, and &lt;code&gt;x-default&lt;/code&gt;. Right language, right pairs, right default.&lt;/p&gt;

&lt;p&gt;The commit message was accurate. The site was also serving &lt;code&gt;&amp;lt;html lang="en"&amp;gt;&lt;/code&gt; on every Spanish post, with zero hreflang annotations.&lt;/p&gt;

&lt;p&gt;Both of those sentences are true. They are true of different surfaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  The annotation that was there and wasn't
&lt;/h2&gt;

&lt;p&gt;This site is a React 19 SPA on Vercel with a build-time prerender step. The prerender writes the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; into the static HTML so that crawlers get titles and meta tags without executing anything. &lt;code&gt;SEO.tsx&lt;/code&gt; is a React component. It runs in the browser, after React mounts, and it does exactly what its commit message claims.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scripts/prerender.mjs&lt;/code&gt; writes the HTML the server actually sends. At that commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;git show 86569b0:scripts/prerender.mjs | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; hreflang
&lt;span class="go"&gt;0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prerender rewrote only the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; element. It never touched the &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; tag at all, so the &lt;code&gt;lang&lt;/code&gt; attribute stayed at whatever &lt;code&gt;index.html&lt;/code&gt; had baked in, which was &lt;code&gt;en&lt;/code&gt;. And it emitted no alternates, because nothing in it knew the translation table existed.&lt;/p&gt;

&lt;p&gt;I confirmed it with &lt;code&gt;curl&lt;/code&gt; on all four paired URLs — English original and Spanish translation, both directions. Then I tried again with a Googlebot user-agent, in case Vercel was doing something clever for crawlers. Byte-identical response. There was no second delivery path. The sitemap had no &lt;code&gt;xhtml:link&lt;/code&gt; alternates either.&lt;/p&gt;

&lt;p&gt;There is a trap in this one worth naming, because it is what lets the mistake survive for a while. Every page on the site serves this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"application/rss+xml"&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grep the served HTML for &lt;code&gt;alternate&lt;/code&gt; and you find a hit. If you are checking quickly, and you are already fairly sure the feature works because you watched it work, a hit is enough. The string you searched for was present. It was the feed.&lt;/p&gt;

&lt;p&gt;The practical consequence is narrower than it sounds. Google renders JavaScript, so for Google this was a delay rather than a loss. The exposure was to engines that do not render, which saw Spanish posts declared as English with no indication that a translation existed.&lt;/p&gt;

&lt;p&gt;Fixed on 21 August. &lt;code&gt;prerender.mjs&lt;/code&gt; now sets &lt;code&gt;lang&lt;/code&gt; on the &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; element and emits the alternates, reading &lt;code&gt;src/config/translations.json&lt;/code&gt; — the same file &lt;code&gt;src/config/translations.ts&lt;/code&gt; reads. The table lives in JSON because a &lt;code&gt;.mjs&lt;/code&gt; build script cannot import a &lt;code&gt;.ts&lt;/code&gt; module, and keeping two copies of it is precisely how the two halves would drift apart again.&lt;/p&gt;

&lt;p&gt;Verified after: &lt;code&gt;lang="es"&lt;/code&gt; on the two Spanish posts, &lt;code&gt;lang="en"&lt;/code&gt; on the two English ones, three hreflang links on each of the four paired posts, and zero on an unpaired post like &lt;code&gt;/tailwind-css&lt;/code&gt; — which is the correct number, since an hreflang pointing at a translation that doesn't exist is a false claim.&lt;/p&gt;

&lt;p&gt;I did not verify the wrong thing here. I verified a real thing on the surface that was easier to reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  The slot no test was watching
&lt;/h2&gt;

&lt;p&gt;The second one isn't about checking the wrong surface. There was no surface to check.&lt;/p&gt;

&lt;p&gt;Every post carries two ad units: one in-article, one at the foot of the post. On 20 August at 22:48 I shipped this line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-ad-status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-response&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three seconds after mount, if AdSense had not written a verdict onto the element, the code invented one. The CSS collapsed the container on &lt;code&gt;no-response&lt;/code&gt;, so the slot went &lt;code&gt;display: none&lt;/code&gt; — while AdSense was still measuring it. An ad cannot be placed into a hidden, zero-width box. The guess made itself come true.&lt;/p&gt;

&lt;p&gt;That was live for about ten hours. The fix, at 08:46 the next morning, removed the timeout entirely: only AdSense's own verdict changes the state, and a missing verdict means "pending", which reserves space instead of collapsing it.&lt;/p&gt;

&lt;p&gt;That installed the exact mirror of the bug it fixed.&lt;/p&gt;

&lt;p&gt;In production, the end-of-post unit reaches &lt;code&gt;data-adsbygoogle-status="done"&lt;/code&gt; and then never receives &lt;code&gt;data-ad-status&lt;/code&gt; at all. Under the new rule, "never" is indistinguishable from "not yet". It sat in &lt;code&gt;pending&lt;/code&gt; permanently. Measured in a real browser on 23 August, on /portfolio-audit-20-problems, both slots at the same moment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;slot 9344511662  (end of post)
  data-adsbygoogle-status   "done"
  data-ad-status            null
  iframes                   0
  height                    303px
  display                   block
  "Advertisement" label     visible

slot 2970675007  (in-article)
  data-adsbygoogle-status   "done"
  data-ad-status            "unfilled"
  height                    0px
  display                   none
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same page, same load. The asymmetry was in my state machine, not in AdSense.&lt;/p&gt;

&lt;p&gt;So for two and a half days, every post on this site ended with an empty rectangle labeled "Advertisement" — 303 pixels tall on the page I measured. Nothing was red. No test failed. The build passed, the page rendered. &lt;/p&gt;

&lt;p&gt;The real fix: the grace period now starts only once AdSense itself reports &lt;code&gt;done&lt;/code&gt;, and the observer stays attached afterward so a late verdict still wins. It also reads the attribute once immediately on attach, because a &lt;code&gt;MutationObserver&lt;/code&gt; tells you nothing about what happened before you called &lt;code&gt;observe()&lt;/code&gt;. Verified after: both slots 0px, &lt;code&gt;display: none&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Green because nothing is asking
&lt;/h2&gt;

&lt;p&gt;The two failures are not the same shape.&lt;/p&gt;

&lt;p&gt;In the hreflang case there was a check and it was watching the browser DOM instead of the response body. Wrong surface, honest mistake, findable.&lt;/p&gt;

&lt;p&gt;In the ad case there was no check. Not a weak one — none. And from outside, "no check" and "check passed" produce the identical signal: a build that goes green. Absence of a failing test looks exactly like presence of a passing one when all you can see is the color. A missing assertion doesn't announce itself the way a broken one does.&lt;/p&gt;

&lt;p&gt;That is why the empty box lasted two and a half days and the hreflang bug lasted until I happened to run &lt;code&gt;curl&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thread this came from
&lt;/h2&gt;

&lt;p&gt;I'm writing this because of a comment. On the &lt;a href="https://dev.to/gabbs279/how-i-actually-code-with-claude-code-my-real-workflow-on-a-real-project-4ao0"&gt;dev.to cross-post of "How I Actually Code with Claude Code"&lt;/a&gt;, Heinrich Neb put it better than I can: a check can be green and blind. His example was a test asserting that a benchmark number appeared in his server's output — where the number was a hardcoded string. The check guarded the sentence, not the measurement. It stayed green for weeks. Debashish Ghosal, in the same thread, suggested writing an outcome note onto each plan file after execution, which is a different attack on the same problem: make the record say what happened, not what was intended.&lt;/p&gt;

&lt;p&gt;I don't have a general solution. I'm not going to end this with a testing philosophy, because I'd be inventing one on the spot to make the post feel finished.&lt;/p&gt;

&lt;p&gt;What I actually have is a habit: open a browser, find the specific element, and measure it. Not the framework's idea of the element — the element. &lt;code&gt;curl&lt;/code&gt; the URL instead of trusting the component that writes the tag. Read the height off the box instead of trusting the state that decides the height.&lt;/p&gt;

&lt;p&gt;That is how both of these turned up: one at a time, by hand, because I happened to look. It would not have found a third.&lt;/p&gt;

&lt;p&gt;I write up the things I break and fix at &lt;a href="https://codewithgabo.com/allpost?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=green-and-blind" rel="noopener noreferrer"&gt;codewithgabo.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>webdev</category>
      <category>seo</category>
      <category>programming</category>
    </item>
    <item>
      <title>How I Actually Code with Claude Code: My Real Workflow on a Real Project</title>
      <dc:creator>Gabriel Abreu</dc:creator>
      <pubDate>Mon, 24 Aug 2026 04:02:37 +0000</pubDate>
      <link>https://dev.to/gabbs279/how-i-actually-code-with-claude-code-my-real-workflow-on-a-real-project-4ao0</link>
      <guid>https://dev.to/gabbs279/how-i-actually-code-with-claude-code-my-real-workflow-on-a-real-project-4ao0</guid>
      <description>&lt;p&gt;There are two kinds of articles about coding with AI. The ones that generate a sorting function and conclude the profession is over, and the ones that show a dumb bug and conclude none of this works.&lt;/p&gt;

&lt;p&gt;Neither one looks anything like my actual workday.&lt;/p&gt;

&lt;p&gt;This is the third kind: the concrete workflow I use with Claude Code on this very site, three tasks I actually delegated, the trail in the repository to back it up, and a section on where it fails that runs as long as the section on where it works. That's the part I would have wanted to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is, without the marketing
&lt;/h2&gt;

&lt;p&gt;Claude Code is an agent that runs in your terminal, inside your repository. It reads your files, runs your commands, edits your code, makes your commits. It isn't editor autocomplete, and it isn't a separate chat window where you paste fragments back and forth.&lt;/p&gt;

&lt;p&gt;That difference matters more than it sounds. An assistant that sees one file helps you write a function. An agent that sees the whole repo, runs the tests and reads the output can take a complete task off your hands. It's the difference between asking for advice and delegating work.&lt;/p&gt;

&lt;h2&gt;
  
  
  My setup
&lt;/h2&gt;

&lt;p&gt;Nothing exotic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The repo for this site: a React frontend on Vite, Sanity as the CMS, and a small backend of Vercel Functions.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;.claude/&lt;/code&gt; folder in the project with the local config and permissions.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;docs/plans/&lt;/code&gt; folder where every design and plan lives. This is the key piece, and I'll explain why in a second.&lt;/li&gt;
&lt;li&gt;Git worktrees when a big task deserves isolation from the current working tree.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one rule of my own, which is what actually changed my results: &lt;strong&gt;nothing big gets written without a written plan first.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow: design, plan, execute
&lt;/h2&gt;

&lt;p&gt;The temptation with an agent is to open the terminal and say "add me an admin panel." Sometimes that works. Often it produces something that runs but isn't what you wanted, and you find out after six hundred lines are already on disk.&lt;/p&gt;

&lt;p&gt;So I split it into three phases, and I don't let them overlap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One: design.&lt;/strong&gt; Before touching code, a conversation. What problem are we solving, two or three approaches with their downsides, which one we pick and why. Out comes a design document in &lt;code&gt;docs/plans/&lt;/code&gt;. Short, but written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two: plan.&lt;/strong&gt; The design turns into a task-by-task plan. Which files each task touches, which test gets written first, which command verifies it, where the commit goes. My plans folder has files like &lt;code&gt;2026-04-28-slice-2-vercel-migration.md&lt;/code&gt; and &lt;code&gt;2026-04-29-portfolio-audit-implementation.md&lt;/code&gt;. Each one is a plan that got executed task by task.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three: execute.&lt;/strong&gt; Now the code. With the plan in front of it, the session doesn't drift. If something doesn't fit, it shows up right away, because there's a document saying what should be happening.&lt;/p&gt;

&lt;p&gt;It sounds like bureaucracy, and I thought so at first. It isn't. The written plan is what turns "the agent did something weird" into "the agent went off script at step 4," which is a very different problem and a much easier one to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three tasks I actually delegated
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The portfolio audit
&lt;/h3&gt;

&lt;p&gt;I asked it to audit this site as if it belonged to a client. Read every public component, walk the routes on mobile and desktop, measure the build chunk sizes, count the real posts in Sanity.&lt;/p&gt;

&lt;p&gt;It came back with &lt;strong&gt;20 findings across four areas&lt;/strong&gt;, each with a priority and an effort estimate. Among them: a 993 KB unoptimized illustration, a sitemap containing zero of nine published posts, and a 404 page that hung forever on "Loading post...".&lt;/p&gt;

&lt;p&gt;I wrote that one up in detail &lt;a href="https://codewithgabo.com/portfolio-audit-20-problems?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=claude-code" rel="noopener noreferrer"&gt;in a separate post&lt;/a&gt;, but what matters here is the division of labor: &lt;strong&gt;the machine found, I prioritized.&lt;/strong&gt; The document came back with findings and no fixes, on purpose, so I would be the one deciding what got touched and what didn't. Three of the twenty I threw out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Migrating Express to Vercel Functions
&lt;/h3&gt;

&lt;p&gt;I had a small Express server on Railway wrapping the Google Analytics API. It worked, but it cost money every month to sit there 24/7 serving a dashboard almost nobody visits.&lt;/p&gt;

&lt;p&gt;Moving it to serverless functions was textbook work: pull the helpers into &lt;code&gt;api/_utils/&lt;/code&gt;, turn the route into a handler, move the environment variables, verify the external contract didn't change. The frontend never noticed. The bill went to zero.&lt;/p&gt;

&lt;p&gt;This is exactly the kind of task where delegating wins: mechanical, well defined, with an objective success criterion. It doesn't take judgment, it takes not getting twenty details in a row wrong. A machine does that better than I do at eleven at night.&lt;/p&gt;

&lt;h3&gt;
  
  
  The sitemap generated from Sanity
&lt;/h3&gt;

&lt;p&gt;My sitemap was a static file I had to edit by hand every time I published. Which is to say: never.&lt;/p&gt;

&lt;p&gt;Now it's a script that runs on &lt;code&gt;prebuild&lt;/code&gt;, queries Sanity with GROQ, and writes the file with every post and its real date. Eighty lines of Node, no new dependencies.&lt;/p&gt;

&lt;p&gt;Small task, and that's exactly why it sat there for months. That's the pattern I've noticed most: what has saved me the most time isn't the big tasks, it's the forty-minute ones that had been on the list for half a year because they were never urgent enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it fails
&lt;/h2&gt;

&lt;p&gt;This is where most of these articles go blurry. Straight to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It accepts your premise too fast.&lt;/strong&gt; If you say "fix this CSS bug," it will go looking for a CSS bug. If the real problem was route ordering, you may get a CSS fix that covers the symptom. Now, when something breaks, I describe the behavior and not my diagnosis. The difference in outcome is enormous.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's too agreeable about your ideas.&lt;/strong&gt; If you propose a bad approach, the default response tends to be helping you build it well. I explicitly ask for two or three options with their downsides before anything gets decided, because if I don't ask for alternatives, they don't show up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context degrades in long sessions.&lt;/strong&gt; Over a session of several hours, the decisions from the first half hour get fuzzy. That's why the plan goes into a file instead of staying in the conversation: the file doesn't forget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't know what ugly looks like.&lt;/strong&gt; It can write a component that is correct, accessible, passes the tests, and looks bad. Visual judgment is still yours. On this site I've redone by hand a fair amount of CSS that was technically fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the big one: you are still responsible for what gets merged.&lt;/strong&gt; I read every diff. Not because I trust it less than a human colleague, but exactly as much as I'd check a human colleague. A commit with my name on it is mine, no matter who typed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it costs more than it saves:&lt;/strong&gt; tasks under ten minutes that I already know how to do, one-line changes, and anything where explaining the context takes longer than doing the thing. Writing a good prompt for a trivial change is net negative work.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to start tomorrow
&lt;/h2&gt;

&lt;p&gt;If you want to try this without getting burned:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start with a boring, well-defined task.&lt;/strong&gt; Migrating a format, writing tests for something that already exists, updating dependencies. Don't start with your product's flagship feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask for a plan before code.&lt;/strong&gt; Even when the task is small. Reading the plan tells you in thirty seconds whether you were understood, and fixing a plan is free compared to fixing an implementation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Work on a branch or a worktree.&lt;/strong&gt; So you can throw it all away without thinking twice if it goes sideways.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the diffs.&lt;/strong&gt; All of them. If you're not going to read them, don't delegate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave the context in the repository, in writing.&lt;/strong&gt; Decisions that live only in a conversation get lost. The ones in &lt;code&gt;docs/plans/&lt;/code&gt; are still there three months later, when you no longer remember why you chose that.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What actually changed
&lt;/h2&gt;

&lt;p&gt;I don't code faster. I code with less friction on the boring parts, which turn out to be most of the work.&lt;/p&gt;

&lt;p&gt;The tasks that used to sit on the list out of inertia now get done, because the cost of starting them dropped enough. The sitemap had been pending for months. The audit for longer. Neither was hard; both were tedious, and tedious is exactly what piled up on me.&lt;/p&gt;

&lt;p&gt;What didn't change: I still decide what gets built, I still review every line that goes in, and I'm still the one responsible when something breaks in production.&lt;/p&gt;

&lt;p&gt;That seems right to me. It's the part of the job I like.&lt;/p&gt;

&lt;p&gt;I write up the things I break and fix at &lt;a href="https://codewithgabo.com/allpost?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=claude-code" rel="noopener noreferrer"&gt;codewithgabo.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Audited My Own Portfolio and Found 20 Problems</title>
      <dc:creator>Gabriel Abreu</dc:creator>
      <pubDate>Fri, 21 Aug 2026 20:25:06 +0000</pubDate>
      <link>https://dev.to/gabbs279/i-audited-my-own-portfolio-and-found-20-problems-o84</link>
      <guid>https://dev.to/gabbs279/i-audited-my-own-portfolio-and-found-20-problems-o84</guid>
      <description>&lt;p&gt;Auditing someone else's site is easy. You open devtools, point at what's broken, send the invoice, leave. Auditing your own is a different job, because every bad decision in it is yours, most of them made around eleven at night on a Tuesday, and you have spent months certain they were fine.&lt;/p&gt;

&lt;p&gt;So I sat down with codewithgabo.com and reviewed it like it belonged to a client. No excuses, no "I'll get to that later." The review turned up &lt;strong&gt;20 findings&lt;/strong&gt; across four areas. Five of them stung.&lt;/p&gt;

&lt;p&gt;This post is the honest list: what I found, why it was there, and which number moved when I fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I audited
&lt;/h2&gt;

&lt;p&gt;There's no trick to it. The method was boring on purpose, because boring is what actually finds things.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I read &lt;strong&gt;every public component&lt;/strong&gt; and every route definition in &lt;code&gt;src/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;I walked the &lt;strong&gt;10 public routes&lt;/strong&gt; in a browser, at 375px and on desktop, writing down each page's &lt;code&gt;h1&lt;/code&gt;, its character count, its internal links, and any broken image.&lt;/li&gt;
&lt;li&gt;I queried Sanity to count the posts and words I actually had, not the ones I assumed I had.&lt;/li&gt;
&lt;li&gt;I ran &lt;code&gt;npm audit&lt;/code&gt; and &lt;code&gt;npm outdated&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;I went through the build output chunk by chunk in &lt;code&gt;build/assets/&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last step is the one that hurts and the one most people skip. Looking at the real weight of what you ship is like stepping on a scale in January: you already know it's bad, but you need the number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: a 993 KB illustration
&lt;/h2&gt;

&lt;p&gt;The worst of the twenty. On &lt;code&gt;/gabriel-abreu&lt;/code&gt; I was serving an unoptimized PNG of a developer illustration. &lt;strong&gt;993 KB.&lt;/strong&gt; Almost a megabyte of decoration.&lt;/p&gt;

&lt;p&gt;It had company. &lt;code&gt;nobggabo.png&lt;/code&gt; weighed 358 KB and shipped on the home page, the blog index and the repos page, because all three variants of my &lt;code&gt;Greeting&lt;/code&gt; component import it. So those 358 KB sat on the critical path of the three pages people land on first.&lt;/p&gt;

&lt;p&gt;The fix has no technical merit whatsoever: convert to WebP at quality 80.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;developer-illustration:  993 KB  -&amp;gt;  23 KB   (-98%)
nobggabo:                358 KB  -&amp;gt;  45 KB   (-87%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ninety-eight percent. Nearly a megabyte, for a drawing that looks identical. The credit isn't in the conversion. It's in finally bothering to look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; if you've never checked what your images weigh, that is your biggest problem right now. Statistically, it just is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: the sitemap contained zero posts
&lt;/h2&gt;

&lt;p&gt;I had 9 published posts. My &lt;code&gt;sitemap.xml&lt;/code&gt; listed &lt;strong&gt;7 static pages and no posts at all&lt;/strong&gt;. The &lt;code&gt;lastmod&lt;/code&gt; dates were frozen months in the past on top of that.&lt;/p&gt;

&lt;p&gt;Which means I'd been writing content and then not showing it to Google. All of the writing work, none of the distribution work.&lt;/p&gt;

&lt;p&gt;The root cause was that the sitemap was a static file I had to update by hand. By hand means never.&lt;/p&gt;

&lt;p&gt;It's now generated at build time from a GROQ query against Sanity, running as &lt;code&gt;prebuild&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*[_type=="post" &amp;amp;&amp;amp; !(_id in path("drafts.**")) &amp;amp;&amp;amp; defined(slug.current)]{
  "slug": slug.current,
  _updatedAt
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;npm run build&lt;/code&gt; writes a fresh &lt;code&gt;sitemap.xml&lt;/code&gt; with all the posts and real dates. It no longer depends on me remembering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; anything that depends on your memory is already broken. You just haven't found out yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: my 404 page didn't exist in practice
&lt;/h2&gt;

&lt;p&gt;This is the one I'm most embarrassed by, because it was a real bug and not an oversight.&lt;/p&gt;

&lt;p&gt;If you typed &lt;code&gt;codewithgabo.com/anything&lt;/code&gt;, the site hung forever on &lt;strong&gt;"Loading post..."&lt;/strong&gt;. No 404. No error message. Just a spinner, until you got bored and closed the tab.&lt;/p&gt;

&lt;p&gt;The cause: my post route, &lt;code&gt;/:slug&lt;/code&gt;, caught any unknown URL before it could ever reach the &lt;code&gt;*&lt;/code&gt; route that renders &lt;code&gt;NotFound&lt;/code&gt;. &lt;code&gt;OnePost&lt;/code&gt; queried Sanity, got nothing back, and since it didn't distinguish "still loading" from "this doesn't exist," it sat in the loading state indefinitely.&lt;/p&gt;

&lt;p&gt;The fix was separating those two states:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;postData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPostData&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SanityPostData&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setNotFound&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotFound&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;postData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LoadingSpinner&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Loading post..."&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines. One extra &lt;code&gt;useState&lt;/code&gt; and two returns in the right order. That was the fix for a bug that had spent months quietly turning visitors away.&lt;/p&gt;

&lt;p&gt;While I was in there I gave the 404 page an actual design: the big number, a bilingual heading, and three buttons that take you somewhere useful instead of leaving you stranded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; a loading state that never resolves looks exactly like "slow." That's why nobody reports it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 4: a feature that hadn't rendered in months
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;data.ts&lt;/code&gt; I have three projects tagged &lt;code&gt;badge: "New"&lt;/code&gt;: Analytics Dashboard, NegocioRD and A2C International. The &lt;code&gt;Card&lt;/code&gt; component accepts a &lt;code&gt;badge&lt;/code&gt; prop and renders it as a pill.&lt;/p&gt;

&lt;p&gt;The pill never appeared. Not once.&lt;/p&gt;

&lt;p&gt;Why? Because &lt;code&gt;Portfolio.tsx&lt;/code&gt; destructured &lt;code&gt;image&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt; and &lt;code&gt;languages&lt;/code&gt; off each project, and forgot &lt;code&gt;badge&lt;/code&gt;. The data existed. The component that renders it existed. Nobody had ever introduced the two to each other.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;  description={project.description}
  url={project.url}
  languages={project.languages}
&lt;span class="gi"&gt;+ badge={project.badge}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One line.&lt;/strong&gt; I wrote the data, I wrote the component, and I left out the wire between them. It sat like that for months, and I never noticed because I never looked at the project grid asking "does this look the way it should?" I looked at it asking "does it load?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; check your UI against what it's &lt;em&gt;supposed&lt;/em&gt; to show, not against what it shows. Those are different questions and only one of them catches this bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 5: 405 KB of charting library for people who hadn't asked for charts
&lt;/h2&gt;

&lt;p&gt;I keep an analytics dashboard at &lt;code&gt;/dashboard-demo&lt;/code&gt;, public, so the work is visible. It uses recharts.&lt;/p&gt;

&lt;p&gt;Recharts is heavy. And it was sitting inside the chunk loaded eagerly when you hit the route. So anyone who opened the demo downloaded &lt;strong&gt;405 KB&lt;/strong&gt; of charting library before deciding whether they even cared.&lt;/p&gt;

&lt;p&gt;The fix was wrapping &lt;code&gt;ChartCard&lt;/code&gt; in &lt;code&gt;React.lazy&lt;/code&gt; with &lt;code&gt;Suspense&lt;/code&gt;, so recharts becomes its own chunk and only comes down when the charts are actually about to mount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before:  useAnalyticsData chunk = 405 KB   (recharts inside)
after:   useAnalyticsData chunk =   3 KB
         ChartCard chunk        = 411 KB   (lazy)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;405 KB down to 3 KB on initial load. The library still weighs what it weighs, obviously. What changed is &lt;em&gt;when&lt;/em&gt; you pay for it, and who does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; code splitting doesn't make your app smaller. It stops the people who bounce from paying for what they never used.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other fifteen
&lt;/h2&gt;

&lt;p&gt;Not all of them were dramatic. Six unused images sitting in the repo, more than a megabyte of dead weight. One moderate vulnerability in a dependency. A page, &lt;code&gt;/education&lt;/code&gt;, that existed and rendered content but wasn't linked from anywhere in the menu. Forgotten &lt;code&gt;console.log&lt;/code&gt; and &lt;code&gt;console.warn&lt;/code&gt; calls in five files. No privacy policy page and no terms page.&lt;/p&gt;

&lt;p&gt;There were also things that were fine, which is worth writing down so you don't drive yourself crazy: the mobile layout was clean, the content density was good, and the ad configuration was correct. An honest audit also tells you what to leave alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to audit yours
&lt;/h2&gt;

&lt;p&gt;If you're up for it, this is the order I'd go in. It's sorted by return per hour spent:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Weigh your images.&lt;/strong&gt; Sort by size and look at the top three. If anything is over 200 KB, there's your afternoon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type a URL that doesn't exist&lt;/strong&gt; on your own site. Watch what happens. Count to ten. If it's still loading, you have my finding number 3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open your sitemap&lt;/strong&gt; and count the URLs. Is your actual content in there? Are the dates real?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at your build chunk sizes.&lt;/strong&gt; Anything over 300 KB that isn't required for the first paint is a candidate for lazy loading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walk every page asking what it should be showing&lt;/strong&gt;, not whether it loads. That's where bugs like the badge one turn up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run &lt;code&gt;npm audit&lt;/code&gt; and &lt;code&gt;npm outdated&lt;/code&gt;.&lt;/strong&gt; Ten seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep the project for &lt;code&gt;console.log&lt;/code&gt;.&lt;/strong&gt; Another ten seconds.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All seven steps fit in an afternoon. It took me one afternoon to find these and another to fix the ones that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually learned
&lt;/h2&gt;

&lt;p&gt;None of these problems were hard. Not one. The most complex fix was three lines; the highest-impact one was converting a PNG to WebP, which is a single command.&lt;/p&gt;

&lt;p&gt;They weren't there because they were difficult. They were there because &lt;strong&gt;I never sat down and looked.&lt;/strong&gt; Building is more fun than reviewing, so you keep building on top, and the broken things stay underneath, holding up the building with a months-old bug.&lt;/p&gt;

&lt;p&gt;If you have a portfolio, a blog or a side project you haven't honestly looked at in a while: put two hours on the calendar this week. Not to add anything. Just to look.&lt;/p&gt;

&lt;p&gt;You'll find your own 993 KB PNG. I promise.&lt;/p&gt;

&lt;p&gt;I write up the things I break and fix at &lt;a href="https://codewithgabo.com/allpost?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=portfolio-audit" rel="noopener noreferrer"&gt;codewithgabo.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>seo</category>
      <category>react</category>
    </item>
    <item>
      <title>Migrating an Express Backend to Vercel Functions Without Downtime</title>
      <dc:creator>Gabriel Abreu</dc:creator>
      <pubDate>Fri, 21 Aug 2026 19:59:20 +0000</pubDate>
      <link>https://dev.to/gabbs279/migrating-an-express-backend-to-vercel-functions-without-downtime-3kal</link>
      <guid>https://dev.to/gabbs279/migrating-an-express-backend-to-vercel-functions-without-downtime-3kal</guid>
      <description>&lt;p&gt;Earlier this year I migrated the analytics backend behind &lt;a href="https://codewithgabo.com/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=vercel-migration" rel="noopener noreferrer"&gt;this site&lt;/a&gt; from a long-running Express server on Railway to a set of Vercel Functions. The frontend never noticed. Cost dropped from a small monthly Railway bill to &lt;strong&gt;$0/month&lt;/strong&gt; on Vercel's hobby tier. This post walks through how I did it, what broke, and when this kind of migration actually makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why migrate at all
&lt;/h2&gt;

&lt;p&gt;The backend was a tiny Express server that did two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Wrap the Google Analytics 4 Data API behind an admin-only endpoint so the dashboard on this site could read GA4 metrics without exposing the service-account key.&lt;/li&gt;
&lt;li&gt;Validate an &lt;code&gt;ADMIN_TOKEN&lt;/code&gt; to gate that endpoint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That was it. A few hundred lines of Node, three dependencies, one route. Running it as a 24/7 container on Railway was fine, but it had three quiet costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Money.&lt;/strong&gt; Hobby tier on Railway isn't free anymore. Even a small workload was a recurring charge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold starts I didn't need.&lt;/strong&gt; The dashboard isn't visited often. The Express server sat idle 99% of the time, costing money to do nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy ceremony.&lt;/strong&gt; Two repositories, two pipelines, two environments to keep aligned. Every backend change meant context-switching to the Railway dashboard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Vercel Functions felt like the right shape: stateless handlers that spin up on request, scale to zero when idle, deploy from the same &lt;code&gt;git push&lt;/code&gt; flow as anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The before / after
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEFORE:
  Frontend
        │ HTTPS
        ▼
  Railway: long-running Node + Express
        ├─ /api/analytics
        ├─ middleware: cors, helmet
        └─ env: ADMIN_TOKEN, GA4 credentials

AFTER:
  Frontend
        │ HTTPS
        ▼
  Vercel Functions
        ├─ api/analytics.js (one handler per route)
        ├─ api/health.js
        └─ api/_utils/ (extracted helpers — auth, ga4 client)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same external contract, completely different runtime model. The frontend was not part of this migration — it was still on GitHub Pages at the time, and has since moved to Vercel too, but that was a separate change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Turn each Express route into a Vercel handler
&lt;/h2&gt;

&lt;p&gt;A Vercel Function is just a Node module that exports a handler matching &lt;code&gt;(req, res) =&amp;gt; void | Promise&amp;lt;void&amp;gt;&lt;/code&gt;. The shape is essentially &lt;code&gt;http.IncomingMessage&lt;/code&gt; / &lt;code&gt;http.ServerResponse&lt;/code&gt; with some extras.&lt;/p&gt;

&lt;p&gt;The Express version looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/analytics&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requireAuth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endDate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runAnalyticsQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Vercel version is the same logic, no &lt;code&gt;app&lt;/code&gt;, no &lt;code&gt;next&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After: api/analytics.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;applyCors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requireAuth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./_utils/auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;runAnalyticsQuery&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./_utils/ga4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;applyCors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;405&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Method not allowed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;requireAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endDate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runAnalyticsQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No middleware chain.&lt;/strong&gt; Each handler runs the few checks it needs explicitly. With one or two routes per file, this is clearer than a global middleware stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CORS becomes a function call.&lt;/strong&gt; I extracted &lt;code&gt;applyCors&lt;/code&gt; to share between handlers. It returns &lt;code&gt;false&lt;/code&gt; and writes the response if the request was a preflight, so the handler can early-exit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth, same.&lt;/strong&gt; &lt;code&gt;requireAuth&lt;/code&gt; reads the &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt; header, compares to &lt;code&gt;process.env.ADMIN_TOKEN&lt;/code&gt;, and writes a 401 if it fails.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2 — Extract helpers into &lt;code&gt;api/_utils/&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Vercel ships every file in &lt;code&gt;api/&lt;/code&gt; (excluding &lt;code&gt;_&lt;/code&gt;-prefixed paths) as a public route. Anything starting with &lt;code&gt;_&lt;/code&gt; is treated as a private utility. This is the convention I used for shared code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;api/
├── analytics.js
├── health.js
└── _utils/
    ├── auth.js
    └── ga4.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GA4 helper memoises the client across invocations, which matters because Vercel Functions reuse warm container instances when traffic is steady:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// api/_utils/ga4.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BetaAnalyticsDataClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@google-analytics/data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BetaAnalyticsDataClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;client_email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GA4_CLIENT_EMAIL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;private_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GA4_PRIVATE_KEY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sr"&gt;n/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;replace(/\\n/g, '\n')&lt;/code&gt; is the one consistent pain point of moving service-account keys between secret stores. Vercel preserves real newlines in multi-line env values, so if you paste with literal &lt;code&gt;\n&lt;/code&gt; from a copied JSON file, you have to undo the escaping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Configure &lt;code&gt;vercel.json&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Vercel infers most of the project layout, but it's worth being explicit about CORS origins and Node version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"functions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"api/**/*.js"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"runtime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nodejs20.x"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/(.*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Access-Control-Allow-Origin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://codewithgabo.com"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I keep the dynamic origin allowlist (which supports multiple domains during development) inside &lt;code&gt;applyCors&lt;/code&gt; and use the static header here only as a defense-in-depth layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Migrate environment variables
&lt;/h2&gt;

&lt;p&gt;I copied each env var from Railway's dashboard to Vercel's, set them for &lt;strong&gt;Production + Preview + Development&lt;/strong&gt; so dev runs (&lt;code&gt;vercel dev&lt;/code&gt;) work locally. The two non-trivial ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GA4_PRIVATE_KEY&lt;/code&gt; — multi-line value with real newlines. Pasted directly; no escaping.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FRONTEND_URL&lt;/code&gt; — a comma-separated list (&lt;code&gt;https://codewithgabo.com,https://gabbs27.github.io,http://localhost:3000&lt;/code&gt;) consumed by the CORS helper. This was new — Express had it baked into a &lt;code&gt;cors()&lt;/code&gt; middleware config.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 5 — Smoke-test before flipping traffic
&lt;/h2&gt;

&lt;p&gt;The old Railway service was still live during the migration. I deployed Vercel to a preview URL, hit it from &lt;code&gt;curl&lt;/code&gt; with a real &lt;code&gt;ADMIN_TOKEN&lt;/code&gt;, confirmed the JSON response matched the Railway version, then updated the frontend's &lt;code&gt;VITE_ANALYTICS_API_URL&lt;/code&gt; env to point at the new Vercel alias and redeployed.&lt;/p&gt;

&lt;p&gt;Once the dashboard rendered metrics from Vercel, I stopped the Railway service. No DNS games, no routing rules, no reverse proxy cutover. The frontend rebuild was the cutover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three gotchas I hit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. File uploads.&lt;/strong&gt; A later endpoint (&lt;code&gt;api/upload.js&lt;/code&gt;) accepts a multipart image and proxies it to Sanity's asset API. Express handled multipart through &lt;code&gt;multer&lt;/code&gt; middleware. On Vercel I had to parse the multipart body manually because the platform's default body parser only handles JSON. Vercel exposes &lt;code&gt;req.body&lt;/code&gt; as a &lt;code&gt;Buffer&lt;/code&gt; for unknown content types — from there it's &lt;code&gt;busboy&lt;/code&gt; or roll-your-own boundary parsing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Cold starts on the first hit of the day.&lt;/strong&gt; A Vercel Function that hasn't been invoked recently can take ~500–1500ms to spin up. For a dashboard that's only checked occasionally, this is fine. For a user-facing API on the critical path, it would be a real concern — pre-warming or pinning the function to a region helps, but it's a tradeoff to keep in mind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The&lt;/strong&gt; &lt;code&gt;_utils/&lt;/code&gt; &lt;strong&gt;import path.&lt;/strong&gt; The first time I deployed, I forgot that Vercel's build doesn't follow files outside &lt;code&gt;api/&lt;/code&gt; automatically. Putting helpers under &lt;code&gt;api/_utils/&lt;/code&gt; (with the leading underscore) lets the bundler find them while keeping them private.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Concrete before / after:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Monthly cost:&lt;/strong&gt; small but recurring → &lt;strong&gt;$0&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold start:&lt;/strong&gt; ~50 ms (always warm on Railway) → 500–1500 ms on first hit, then cached&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy flow:&lt;/strong&gt; separate Railway dashboard → &lt;code&gt;git push&lt;/code&gt; from the same monorepo&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependencies:&lt;/strong&gt; &lt;code&gt;express&lt;/code&gt;, &lt;code&gt;cors&lt;/code&gt;, &lt;code&gt;helmet&lt;/code&gt;, &lt;code&gt;nodemon&lt;/code&gt; → &lt;strong&gt;none&lt;/strong&gt; (the platform handles all of it)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repo footprint:&lt;/strong&gt; &lt;code&gt;server.js&lt;/code&gt;, &lt;code&gt;routes/&lt;/code&gt;, &lt;code&gt;middleware/&lt;/code&gt; → a handful of small handlers under &lt;code&gt;api/&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a small admin endpoint like this one, Vercel Functions are clearly the right fit. For a high-traffic API where every millisecond matters, Railway or Fly would still be the better answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to do this
&lt;/h2&gt;

&lt;p&gt;A migration like this only makes sense if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your traffic is bursty or low — cold starts won't dominate the user experience.&lt;/li&gt;
&lt;li&gt;Your handlers are stateless. Long-lived WebSocket connections, in-memory caches, or background workers don't translate cleanly.&lt;/li&gt;
&lt;li&gt;You can live with your plan's per-invocation timeout. Check the current limit for your tier before you assume a long-running job will fit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're running a chat server, a worker pool, or anything with sticky in-memory state, stay on a long-running container. For everything else — REST endpoints, webhooks, glue between services — serverless is hard to beat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thought
&lt;/h2&gt;

&lt;p&gt;The most underrated part of this migration was deleting &lt;code&gt;server.js&lt;/code&gt;, the &lt;code&gt;routes/&lt;/code&gt; folder, and four packages from &lt;code&gt;package.json&lt;/code&gt;. Less code is less to maintain. Less infrastructure is less to monitor. The new setup does exactly the same thing, on better terms, with fewer moving parts.&lt;/p&gt;

&lt;p&gt;If you have a small backend doing low-traffic admin work and you're paying month after month to keep it running, this is worth half a day of your time.&lt;/p&gt;

&lt;p&gt;I write up the things I break and fix at &lt;a href="https://codewithgabo.com/allpost?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=vercel-migration" rel="noopener noreferrer"&gt;codewithgabo.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>vercel</category>
      <category>serverless</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Axios Or Fetch</title>
      <dc:creator>Gabriel Abreu</dc:creator>
      <pubDate>Sun, 19 Feb 2023 01:30:00 +0000</pubDate>
      <link>https://dev.to/gabbs279/axios-or-fetch-2ocd</link>
      <guid>https://dev.to/gabbs279/axios-or-fetch-2ocd</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faycmrkdqa6u3hi3jluh2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faycmrkdqa6u3hi3jluh2.png" alt="Axios Or Fetch" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Consuming REST APIs is a fundamental part of building modern web applications. In React, several libraries are available to handle this task, including Fetch and Axios. They look almost identical in a simple example, which is why the choice feels arbitrary at first — but they behave differently in the exact places where beginners get stuck. Let's go through both, and then through the differences that actually matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fetch
&lt;/h2&gt;

&lt;p&gt;Fetch is a built-in browser API for making HTTP requests. It is a promise-based API, which means it returns a promise that resolves to a Response object when the request is complete.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// eslint-disable-next-line no-unused-vars&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;TodoList&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setTodos&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jsonplaceholder.typicode.com/todos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTodos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;todo&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;todo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;todo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the example above, we're using the useEffect hook to make the API call when the component mounts. We then use the &lt;code&gt;json()&lt;/code&gt; method to convert the response to a JSON object, which we then set as the state of our component using the &lt;code&gt;setTodos&lt;/code&gt; function.&lt;/p&gt;

&lt;h2&gt;
  
  
  Axios
&lt;/h2&gt;

&lt;p&gt;Axios is a popular third-party library for making HTTP requests in JavaScript. It is also promise-based, and it provides a simple API for making requests and handling responses. You install it first with &lt;code&gt;npm install axios&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;axios&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;axios&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// eslint-disable-next-line no-unused-vars&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;TodoList&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setTodos&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jsonplaceholder.typicode.com/todos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTodos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;todo&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;todo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;todo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the example above, we're using the &lt;code&gt;get()&lt;/code&gt; method of the Axios library to make the API call. We then use the &lt;code&gt;data&lt;/code&gt; property of the response object to set the state of our component. Notice there is no &lt;code&gt;json()&lt;/code&gt; step — Axios already parsed the body for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The difference that actually bites you: errors
&lt;/h2&gt;

&lt;p&gt;This is the one I wish someone had told me on day one. &lt;strong&gt;Fetch does not reject on a 404 or a 500.&lt;/strong&gt; As far as fetch is concerned, the server answered, so the promise resolves. Only a network failure (no connection, DNS error, CORS block) rejects it. That means a broken endpoint quietly flows into your success path, &lt;code&gt;response.json()&lt;/code&gt; tries to parse an error page, and you end up debugging the wrong thing.&lt;/p&gt;

&lt;p&gt;You have to check &lt;code&gt;response.ok&lt;/code&gt; yourself. The two snippets below are just the &lt;code&gt;useEffect&lt;/code&gt; from the components above, with one extra piece of state alongside &lt;code&gt;todos&lt;/code&gt; to hold the message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jsonplaceholder.typicode.com/todos/999999&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Request failed with status &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTodos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Axios rejects on any non-2xx status by default, so the failure lands in &lt;code&gt;catch&lt;/code&gt; where you expect it. The error object carries a &lt;code&gt;response&lt;/code&gt; when the server replied, and doesn't when the request never got there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jsonplaceholder.typicode.com/todos/999999&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTodos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Request failed with status &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are fine. Fetch just makes you opt in to the behaviour most people assumed they already had.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning up when the component unmounts
&lt;/h2&gt;

&lt;p&gt;If the user navigates away before your request finishes, your &lt;code&gt;.then&lt;/code&gt; still runs and calls &lt;code&gt;setTodos&lt;/code&gt; on a component that no longer exists. The fix is an &lt;code&gt;AbortController&lt;/code&gt; and a cleanup function returned from &lt;code&gt;useEffect&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jsonplaceholder.typicode.com/todos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTodos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AbortError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Axios accepts the same &lt;code&gt;AbortController&lt;/code&gt; signal in current versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jsonplaceholder.typicode.com/todos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTodos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isCancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The aborted request throws, so remember to swallow that specific error instead of showing it to the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timeouts
&lt;/h2&gt;

&lt;p&gt;Neither one waits forever by default in a useful way. Axios has a &lt;code&gt;timeout&lt;/code&gt; option in milliseconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jsonplaceholder.typicode.com/todos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ECONNABORTED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;The request timed out&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fetch has no option, but you can pass &lt;code&gt;AbortSignal.timeout()&lt;/code&gt; as the signal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jsonplaceholder.typicode.com/todos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TimeoutError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;The request timed out&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Sending data
&lt;/h2&gt;

&lt;p&gt;With fetch you serialise the body and set the header yourself. Forget the &lt;code&gt;Content-Type&lt;/code&gt; and many APIs will reject the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jsonplaceholder.typicode.com/todos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Write a blog post&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Axios does both for you when you hand it a plain object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jsonplaceholder.typicode.com/todos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Write a blog post&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Instances and interceptors
&lt;/h2&gt;

&lt;p&gt;This is the real reason teams reach for Axios once an app grows. You create one configured client and every call inherits the base URL, the timeout, and the auth header — and you write the "token expired, log the user out" logic once instead of in every component.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/api/client.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;axios&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;axios&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jsonplaceholder.typicode.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interceptors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Authorization&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interceptors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your components then import that file instead of Axios directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../api/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// inside useEffect&lt;/span&gt;
&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/todos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTodos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can build the same thing on top of fetch — it's just a wrapper function you write and maintain yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  So which one should you pick
&lt;/h2&gt;

&lt;p&gt;For a small app with a handful of requests, use fetch. It's built into every modern browser and into recent versions of Node, it's zero bytes of dependency, and the only tax is remembering to check &lt;code&gt;response.ok&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Reach for Axios once you have auth headers on every call, refresh-token logic, a dozen endpoints, or a team that will otherwise each invent their own wrapper. Paying for a dependency to delete that duplication is a good trade.&lt;/p&gt;

&lt;p&gt;Two honest limits. First, Axios is a dependency you have to keep updated, and if you only ever make two GET requests it's weight you don't need. Second — and more important — neither of these solves caching, request deduplication, refetching on focus, or loading and error state. If you find yourself writing the same &lt;code&gt;useState&lt;/code&gt; trio in every component, the answer isn't switching HTTP clients; it's a data-fetching library like React Query or SWR, which sits on top of either one.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Tailwind CSS</title>
      <dc:creator>Gabriel Abreu</dc:creator>
      <pubDate>Fri, 10 Feb 2023 20:05:00 +0000</pubDate>
      <link>https://dev.to/gabbs279/tailwind-css-4och</link>
      <guid>https://dev.to/gabbs279/tailwind-css-4och</guid>
      <description>&lt;p&gt;Having trouble with CSS? Try Tailwind.&lt;/p&gt;

&lt;p&gt;Are you tired of writing countless lines of CSS to style your web pages, only to end up with messy, hard-to-maintain code? Or perhaps you're just starting out with web development and feeling overwhelmed by the complexity of CSS? If so, you might want to consider giving Tailwind CSS a try.&lt;/p&gt;

&lt;p&gt;Tailwind is a utility-first CSS framework. Instead of giving you components like &lt;code&gt;.btn&lt;/code&gt; or &lt;code&gt;.card&lt;/code&gt;, it gives you hundreds of tiny single-purpose classes — &lt;code&gt;p-4&lt;/code&gt;, &lt;code&gt;text-sm&lt;/code&gt;, &lt;code&gt;flex&lt;/code&gt;, &lt;code&gt;rounded-xl&lt;/code&gt; — and you compose them directly in your markup. That sentence is easy to say and hard to picture, so the rest of this post is one small component built both ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same card, twice
&lt;/h2&gt;

&lt;p&gt;Here's a card: an image, a title, a paragraph, a link. First the way most of us learned to write it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;article&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"card"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"card__image"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/coffee.jpg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"A cup of cold brew on a wooden table"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"card__body"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h2&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"card__title"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Cold brew, at home&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"card__text"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Twelve hours, a jar, and coarse ground coffee. That's the whole recipe.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"card__link"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/recipes/cold-brew"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Read the recipe&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/article&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the stylesheet that makes it real:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#e5e7eb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="m"&gt;3px&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="nb"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="m"&gt;-1px&lt;/span&gt; &lt;span class="nb"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card__image&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;block&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;object-fit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card__body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card__title&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.125rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;line-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;600&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#111827&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card__text&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.875rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;line-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.25rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#4b5563&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card__link&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inline-block&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5rem&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.375rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#2563eb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.875rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;text-decoration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card__link&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#1d4ed8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is wrong with that code. But notice what it costs: six class names you had to invent, a naming convention you have to remember, a second file you have to keep in sync with the first, and — the part that actually hurts six months later — no way to tell from the HTML what any of it looks like.&lt;/p&gt;

&lt;p&gt;Now the same card in Tailwind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;article&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"max-w-xs overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"block h-40 w-full object-cover"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/coffee.jpg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"A cup of cold brew on a wooden table"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"p-4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h2&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"mb-2 text-lg font-semibold text-gray-900"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Cold brew, at home&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"mb-4 text-sm text-gray-600"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Twelve hours, a jar, and coarse ground coffee. That's the whole recipe.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/recipes/cold-brew"&lt;/span&gt;
       &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"inline-block rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      Read the recipe
    &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/article&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same card — near enough. The hex values above are v3-era approximations of v4's palette, which is defined in &lt;code&gt;oklch&lt;/code&gt; and renders a slightly different blue. No stylesheet, no invented names, nothing to keep in sync.&lt;/p&gt;

&lt;p&gt;To run it you need the build step wired up, not just the import. On a Vite project — which is how this site is set up — that means installing &lt;code&gt;tailwindcss&lt;/code&gt; and &lt;code&gt;@tailwindcss/vite&lt;/code&gt;, adding &lt;code&gt;tailwindcss()&lt;/code&gt; to the &lt;code&gt;plugins&lt;/code&gt; array in &lt;code&gt;vite.config.ts&lt;/code&gt;, and then putting one line at the top of your CSS file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s1"&gt;"tailwindcss"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  So what does "utility-first" actually mean
&lt;/h2&gt;

&lt;p&gt;Look at &lt;code&gt;p-4&lt;/code&gt;. It is a class whose entire definition is &lt;code&gt;padding: 1rem&lt;/code&gt;. That's it. &lt;code&gt;text-sm&lt;/code&gt; sets a font size and a line height. &lt;code&gt;rounded-md&lt;/code&gt; sets a border radius. Every class does one thing and its name tells you which thing.&lt;/p&gt;

&lt;p&gt;Because each class is a fixed, self-contained rule, three things follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The styles live in the markup, which is where you're already looking.&lt;/strong&gt; You read &lt;code&gt;mb-4 text-sm text-gray-600&lt;/code&gt; and you know exactly what that paragraph looks like without opening another file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You stop naming things.&lt;/strong&gt; Naming is genuinely one of the hardest parts of CSS, and utilities delete the problem. There is no &lt;code&gt;.card__body--compact&lt;/code&gt; to argue about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleting an element deletes its styles.&lt;/strong&gt; No orphaned rules accumulating in a stylesheet nobody dares to clean up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The values aren't arbitrary either. &lt;code&gt;p-1&lt;/code&gt; through &lt;code&gt;p-8&lt;/code&gt; walk a consistent spacing scale, so a page built out of utilities tends to look coherent almost by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Responsive, hover, focus, dark mode
&lt;/h2&gt;

&lt;p&gt;This is the part I would miss most if I stopped using it. Any utility takes a prefix, and the prefix is the condition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;article&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"max-w-xs overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm
                md:flex md:max-w-2xl
                dark:border-gray-800 dark:bg-gray-900"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"block h-40 w-full object-cover md:h-auto md:w-48"&lt;/span&gt;
       &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/coffee.jpg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"A cup of cold brew on a wooden table"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"p-4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h2&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"mb-2 text-lg font-semibold text-gray-900 dark:text-gray-100"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Cold brew, at home&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"mb-4 text-sm text-gray-600 dark:text-gray-400"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Twelve hours, a jar, and coarse ground coffee.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/recipes/cold-brew"&lt;/span&gt;
       &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"inline-block rounded-md bg-blue-600 px-4 py-2 text-sm text-white
              hover:bg-blue-700
              focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900
              dark:bg-blue-500 dark:hover:bg-blue-400"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      Read the recipe
    &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/article&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;md:flex&lt;/code&gt; means "flex, from the medium breakpoint up". &lt;code&gt;hover:bg-blue-700&lt;/code&gt; means "this background, on hover". &lt;code&gt;dark:bg-gray-900&lt;/code&gt; follows the reader's system theme by default. No media queries, no pseudo-class selectors, no jumping between files to check which breakpoint you used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Customising the design tokens
&lt;/h2&gt;

&lt;p&gt;You are not stuck with Tailwind's defaults. In Tailwind v4, configuration moved into your CSS file as an &lt;code&gt;@theme&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s1"&gt;"tailwindcss"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;58%&lt;/span&gt; &lt;span class="m"&gt;0.11&lt;/span&gt; &lt;span class="m"&gt;180&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--color-brand-dark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;48%&lt;/span&gt; &lt;span class="m"&gt;0.10&lt;/span&gt; &lt;span class="m"&gt;180&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--font-display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Inter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ui-sans-serif&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system-ui&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--radius-card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those variables generate classes. &lt;code&gt;--color-brand&lt;/code&gt; gives you &lt;code&gt;bg-brand&lt;/code&gt;, &lt;code&gt;text-brand&lt;/code&gt;, &lt;code&gt;border-brand&lt;/code&gt;; &lt;code&gt;--font-display&lt;/code&gt; gives you &lt;code&gt;font-display&lt;/code&gt;; &lt;code&gt;--radius-card&lt;/code&gt; gives you &lt;code&gt;rounded-card&lt;/code&gt;. So the link becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/recipes/cold-brew"&lt;/span&gt;
   &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"inline-block rounded-card bg-brand px-4 py-2 font-display text-sm text-white hover:bg-brand-dark"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  Read the recipe
&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One note if you're reading older tutorials: projects still on Tailwind v3 configure all of this in a &lt;code&gt;tailwind.config.js&lt;/code&gt; file instead, and that file is not how v4 works.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But now my markup is ugly"
&lt;/h2&gt;

&lt;p&gt;This is the honest complaint about Tailwind and it deserves an honest answer: yes, a long class list is ugly, and the fix is not a shorter class list — it's fewer copies of it.&lt;/p&gt;

&lt;p&gt;If you're writing that card once, leave it inline. If you're writing it twenty times, extract a component. In React:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Card&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;article&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"max-w-xs overflow-hidden rounded-card border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"block h-40 w-full object-cover"&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;alt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"p-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"mb-2 text-lg font-semibold text-gray-900 dark:text-gray-100"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"mb-4 text-sm text-gray-600 dark:text-gray-400"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt;
          &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"inline-block rounded-md bg-brand px-4 py-2 text-sm text-white hover:bg-brand-dark"&lt;/span&gt;
        &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          Read the recipe
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;article&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the ugly class list exists in exactly one place, and every usage reads &lt;code&gt;&amp;lt;Card title="Cold brew, at home" ... /&amp;gt;&lt;/code&gt;. That's better than a CSS class would have been, because the structure is reused too, not just the styles.&lt;/p&gt;

&lt;p&gt;Tailwind also has an &lt;code&gt;@apply&lt;/code&gt; directive that folds utilities into a regular CSS class. It works, and it's the right tool when you can't create a component — but reach for a component first. &lt;code&gt;@apply&lt;/code&gt; quietly reintroduces the naming problem and the second file you were trying to escape.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Tailwind is the wrong choice
&lt;/h2&gt;

&lt;p&gt;I use Tailwind on most things I build, but "most" is not "all".&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A tiny static page.&lt;/strong&gt; If you're styling one landing page with forty lines of CSS, a build step and a new vocabulary are more overhead than the CSS you're avoiding. Just write the CSS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A team that already has a working design system.&lt;/strong&gt; If your company ships a component library people are happy with, replacing it with utilities is a large migration in exchange for a smaller win than you think.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Markup you can't easily edit.&lt;/strong&gt; Utility-first assumes you control the HTML. If your output comes from a CMS's rich text, a third-party widget, or an email template, you can't sprinkle classes onto elements you never see — that's a job for regular CSS selectors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A project where nobody will learn the class names.&lt;/strong&gt; Tailwind has a real learning curve, and its payoff is fluency. If you're handing the project to someone who'll fight the vocabulary for the rest of its life, you've made their job harder, not easier.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're new to Tailwind, its syntax will feel overwhelming at first — I found the same thing. But once you get the hang of it, it's a much more direct way to write CSS, and the card above is the reason why: everything that card looks like is right there in the file you're already reading.&lt;/p&gt;

&lt;p&gt;I write up the things I break and fix at &lt;a href="https://codewithgabo.com/allpost?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=tailwind-css" rel="noopener noreferrer"&gt;codewithgabo.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>css</category>
      <category>tailwindcss</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
