
Estaba revisando [skills.addy.ie](https://skills.addy.ie), el sitio del proyecto Agent Skills en el que colaboro, y lo hacía como casi siempre: con las DevTools abiertas. Es un tic profesional; las abro por costumbre buscando cosas que mejorar, no porque notara que algo iba lento. Y así, casi de refilón, me saltó algo a la vista en la pestaña de red: **cada enlace interno que pulsaba provocaba una redirección 301** antes de cargar la página. No una, no en un sitio concreto: en todas. `/skills`, `/lifecycle`, `/compare`, `/docs/getting-started`... todas respondían primero con un 301 hacia su versión con barra final.

Parece inofensivo, pero un 301 en cada navegación es un round trip entero que quien navega paga antes de que la página empiece siquiera a descargarse. Acabé abriendo una [PR al repositorio](https://github.com/addyosmani/skills.addy.ie/pull/1) para corregirlo. Vamos a ver qué encontré, por qué pasaba, y por qué el fix obvio no era del todo el fix correcto.

## Lo que vi en las DevTools

En la pestaña Network, navegando por el sitio y pulsando los enlaces del menú, el patrón se repetía en cada clic:

```text
GET /skills                 301  220 ms   → /skills/
GET /skills/                200   65 ms
GET /docs/getting-started   301  406 ms   → /docs/getting-started/
GET /docs/getting-started/  200   47 ms
GET /lifecycle              301  251 ms   → /lifecycle/
GET /lifecycle/             200   99 ms
GET /teach                  301  440 ms   → /teach/
GET /teach/                 200   46 ms
GET /compare                301  507 ms   → /compare/
GET /compare/               200   58 ms
```

<figure>
  <picture>
    <img
      sizes="(max-width: 768px) 100vw, 768px"
      srcset="
        https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_600/joanleon.dev/assets/trailing-slash-301-internal-navigation/devtools-network-301-before.png 600w,
        https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_1200/joanleon.dev/assets/trailing-slash-301-internal-navigation/devtools-network-301-before.png 1200w"
      src="https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_1200/joanleon.dev/assets/trailing-slash-301-internal-navigation/devtools-network-301-before.png"
      loading="lazy"
      decoding="async"
      height="460"
      width="732"
      alt="Pestaña Network de Chrome DevTools en skills.addy.ie/compare/ antes del fix: cada ruta interna aparece dos veces, primero con estado 301 Moved Permanently (document / Redirect) y luego con 200 OK, diez peticiones para cinco navegaciones.">
  </picture>
  <figcaption>Antes del fix: cada navegación interna genera un <code>301 Moved Permanently</code> hacia la URL con barra final y, después, el <code>200</code> real. El 301 de <code>/compare</code> se lleva 507 ms él solo. <a href="https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_1600/joanleon.dev/assets/trailing-slash-301-internal-navigation/devtools-network-301-before.png" target="_blank" rel="noopener">Ampliar imagen</a></figcaption>
</figure>

Cada navegación son **dos peticiones**: primero la URL sin barra, que devuelve un `301` con un `Location` apuntando a la misma URL con barra final, y después la URL buena, que ya devuelve el `200` con el documento. El servidor es Netlify.

El coste de esos 301 iba de **220 ms a 507 ms** por clic, y sumaban **1,82 segundos** de latencia solo en saltos de redirección en una sesión de cinco navegaciones. Y no es tiempo de descargar nada útil: si miro el desglose del 301 de `/compare`, casi todo es `wait`, el tiempo de respuesta del servidor:

```text
blocked   1 ms
send      0 ms
wait    505 ms
receive   1 ms
```

Sin DNS ni handshake (la conexión ya estaba abierta y reutilizada), el 301 es un round trip completo cuyo único trabajo es contestar "esta página está un carácter más allá, vuelve a pedirla con la barra". El documento real no empieza a llegar hasta la segunda petición.

## Cómo afecta a la UX

Un 301 aislado no se nota. El problema es que aquí ocurría en **cada navegación interna**, así que el coste no es puntual, es un peaje que se paga una y otra vez durante toda la visita. Quien navega pulsa un enlace y, antes de ver nada, el navegador hace un viaje de ida y vuelta al servidor que no dibuja ni un solo píxel.

Y ese coste empeora justo donde más duele: en redes con latencia alta. En una conexión móvil o lejos del edge de Netlify, un round trip de más no son 200 ms, son fácilmente 400 o 500 ms por clic. Y lo que medí fue con buena conexión, así que cuenta como uno de los mejores casos, no el peor. Si lo multiplicamos por una sesión de diez o quince navegaciones, estamos consumiendo segundos de espera acumulada que no aportan nada.

## El primer fix que se me ocurrió

Lo primero que pensé, viendo que el 301 siempre iba de `/ruta` a `/ruta/`, fue lo evidente: **cambiar los enlaces para que ya apunten a la URL con barra final**. Si el enlace apunta directamente a `/skills/`, no hay nada que redirigir.

```html
<!-- ❌ Bad: link triggers a 301 to /skills/ -->
<a href="/skills">Browse skills</a>

<!-- ✅ Good: link points straight to the canonical URL -->
<a href="/skills/">Browse skills</a>
```

Es correcto, pero es tratar el síntoma enlace por enlace. Antes de tocar nada quería entender por qué el sitio generaba enlaces sin barra si su URL canónica sí la llevaba. Si parcheamos sin entender la causa, el problema se reproduce en cuanto alguien añade el siguiente enlace sin barra.

## Clonar el repo y entender el contexto

Cloné el repositorio y me apoyé en Claude Code para mapear de dónde salían los enlaces y cómo estaba configurado el build. La causa no estaba en un enlace suelto, sino en la combinación de tres cosas:

1. **Astro construye en `directory` format por defecto.** Cada página se emite como `/<ruta>/index.html`, así que su URL canónica **lleva barra final**: `/skills/`.
2. **Los enlaces internos estaban escritos sin la barra.** Y la mayoría no eran literales sueltos, sino generados dinámicamente (los arrays de navegación, las tarjetas del catálogo), por eso no eran evidentes a simple vista.
3. **Netlify, por defecto, canonicaliza con un 301.** Al pedir `/skills`, el host responde con una redirección hacia el `/skills/` que sí existe como directorio. Es el comportamiento estándar del hosting para servir salida en formato directorio.

Falta la pieza que explica por qué esto **no se veía en local**: el servidor de desarrollo de Astro, con el `trailingSlash: 'ignore'` que trae por defecto, sirve tanto `/skills` como `/skills/` con un `200` directo. La redirección solo aparece en el hosting estático de producción. En el entorno de desarrollo, todo parecía perfecto. El bug vivía justo en el hueco entre cómo sirve las URLs el dev server y cómo las sirve Netlify.

## La solución

El fix tiene dos partes, y la primera es la que evita que el problema vuelva.

### 1. Hacer la convención explícita en `astro.config.mjs`

```js
export default defineConfig({
  trailingSlash: "always",
  // ...
});
```

Con `trailingSlash: 'always'` pasan dos cosas buenas: la convención de URLs queda declarada de forma explícita, y el **dev server empieza a redirigir también**. Es decir, el desajuste se vuelve reproducible en local, y cualquier enlace futuro sin barra se detecta antes de llegar a producción. Deja de ser un bug fantasma que solo existe en el entorno de producción.

### 2. Añadir la barra final a todos los enlaces internos

Los literales son directos, pero los que de verdad generaban la mayoría de redirecciones eran los dinámicos, como las tarjetas del catálogo de skills:

```jsx
{/* ❌ Bad */}
href={`/skills/${skill.slug}`}

{/* ✅ Good */}
href={`/skills/${skill.slug}/`}
```

Lo mismo en los arrays de datos de `Nav.astro` y `Footer.astro`, y en los enlaces literales de las páginas. Los **enlaces externos y los assets** (`.svg`, `.png`, `.pptx`...) se quedan intactos: la barra final es cosa de rutas internas, no de ficheros.

### Un matiz sobre SEO

Quitar el 301 de la navegación interna no significa eliminar el 301 del sitio, y así debe ser. Las URLs sin barra que ya estuvieran indexadas o enlazadas desde fuera **van a seguir recibiendo el 301** de Netlify hacia su versión canónica, que es exactamente la canonicalización correcta para SEO: una sola URL válida por página, sin contenido duplicado. Lo que arregla la PR es el salto en la navegación **interna**, que era la fuente de la latencia que se llevaba quien ya estaba dentro del sitio.

### Cómo verificarlo

La comprobación no es "confía en mí", es medible. Validé el antes y el después capturando la actividad de la pestaña Network en la misma sesión de navegación y comparando los dos HAR con ayuda de Claude Code. Tras el fix, queda así:

```text
GET /                       200   86 ms
GET /skills/                200   68 ms
GET /docs/getting-started/  200   56 ms
GET /lifecycle/             200   52 ms
GET /teach/                 200   48 ms
GET /compare/               200   43 ms
```

<figure>
  <picture>
    <img
      sizes="(max-width: 768px) 100vw, 768px"
      srcset="
        https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_600/joanleon.dev/assets/trailing-slash-301-internal-navigation/devtools-network-200-after.png 600w,
        https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_1200/joanleon.dev/assets/trailing-slash-301-internal-navigation/devtools-network-200-after.png 1200w"
      src="https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_1200/joanleon.dev/assets/trailing-slash-301-internal-navigation/devtools-network-200-after.png"
      loading="lazy"
      decoding="async"
      height="460"
      width="732"
      alt="Pestaña Network de Chrome DevTools en skills.addy.ie/compare/ tras el fix: la home y las rutas internas (/skills/, /docs/getting-started/, /lifecycle/, /teach/, /compare/) responden todas con 200 OK directo, sin ninguna fila 301.">
  </picture>
  <figcaption>Después del fix: las mismas rutas devuelven <code>200</code> directo, sin una sola redirección. Las dos peticiones por clic se quedan en una. <a href="https://res.cloudinary.com/nucliweb/image/upload/c_scale,f_auto,w_1600/joanleon.dev/assets/trailing-slash-301-internal-navigation/devtools-network-200-after.png" target="_blank" rel="noopener">Ampliar imagen</a></figcaption>
</figure>

La carga inicial y las cinco navegaciones internas: seis `200` directos, **cero redirecciones**. El peaje del round trip por clic desaparece.

¿Y cómo evitar que se vuelva a colar sin depender de abrir las DevTools a mano? Merece la pena automatizar la vigilancia:

- `trailingSlash: 'always'` es la primera red de seguridad: el dev server redirige, así que un enlace sin barra se nota en local o en los tests E2E antes de desplegar.
- Una regla de lint o un `grep` sobre el `dist` en CI que falle si aparece un enlace interno sin barra.
- La auditoría _Avoid multiple page redirects_ de Lighthouse señala las cadenas de redirección en producción.

## Lo que me llevo

Tres cosas prácticas de este caso:

- **El dev server te puede mentir por omisión.** No porque falle, sino porque sus valores por defecto son más permisivos que los del hosting. Merece la pena abrir las DevTools contra el sitio **real desplegado**, no solo contra `localhost`: hay problemas (redirecciones, cabeceras, caché) que solo existen en producción.
- **La consistencia de la trailing slash importa más de lo que parece.** No es cosmética: un desajuste entre la URL canónica y los enlaces internos se paga en un round trip por clic. Elige una convención y decláralo de forma explícita en la config, para que las herramientas la hagan cumplir por ti.
- **Antes de parchear el síntoma, entiende la causa.** Cambiar los enlaces a mano habría funcionado hoy y se habría roto con el siguiente enlace que alguien añadiera sin barra. `trailingSlash: 'always'` convierte el criterio en algo que el propio proyecto vigila.

Si tienes un sitio en Astro sobre Netlify (o cualquier host que canonicalice directorios), abre las DevTools, navega un poco por dentro y mira la columna de estado. Si ves 301 donde esperabas 200, ya sabes por dónde empezar.
