32blogby Studio Mitsu

Cómo reduje el TTFB de Next.js App Router de 1s a 100ms

El TTFB de 32blog bajó de 1260ms a 100ms al eliminar una sola llamada a headers() en el root layout. Cómo las APIs dinámicas vuelven toda la app dinámica en Next.js App Router, y la solución oficial.

by omitsu11 min read
Contenido

Si tu sitio con Next.js App Router se siente lento (TTFB cercano a 1 segundo, x-vercel-cache: MISS en cada request), revisa si tu app/layout.tsx raíz llama a headers(), cookies() o al getLocale() de next-intl/server. Estas APIs dinámicas en el root layout obligan silenciosamente a que cada ruta de la app se renderice dinámicamente — generateStaticParams y dynamicParams = false quedan ignorados. Publiqué exactamente esta regresión en 32blog.com y no me di cuenta durante dos semanas. Aquí está cómo la diagnostiqué, la solución que cabe en tres archivos y los comandos de verificación que debí haber corrido desde el día uno.

El síntoma: TTFB clavado en 1.26s durante dos semanas

Migré 32blog.com de WordPress a Next.js específicamente porque quería la reputación de rendimiento que viene con el framework. Después de la migración las páginas se sentían algo lentas al click. Me convencí de que "así es App Router" y lo dejé pasar.

No era así.

Esto es lo que medí en las páginas de artículo antes del fix:

bash
curl -s -o /dev/null -w "TTFB: %{time_starttransfer}s\n" https://32blog.com/es/cli/cli-prompt-starship
# TTFB: 1.257s

Los response headers contaban el resto:

cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-vercel-cache: MISS
x-vercel-id: hnd1::iad1::vxsv9-...

Tres banderas rojas:

  1. no-store — el edge CDN de Vercel recibió orden explícita de no cachear
  2. x-vercel-cache: MISS — confirmación de que el CDN se estaba saltando en cada request
  3. hnd1::iad1 — el PoP de edge está en Tokio, pero la función misma corría en iad1 (US East Virginia), añadiendo ~150-180ms de ida y vuelta transatlántica por request

El directorio .next/server/app/ contenía HTML prerrenderizado para cada artículo. Simplemente estaba siendo ignorado en runtime.

El culpable: headers() en el root layout

Corrí npm run build y miré la clasificación de rutas en el output:

├ ƒ /[locale]                    ← Dinámico
├ ƒ /[locale]/[category]         ← Dinámico
├ ƒ /[locale]/[category]/[slug]  ← Dinámico (!)

ƒ significa "server-rendered on demand" (renderizado por request). Pero mis páginas de artículos declaraban explícitamente export const dynamicParams = false y un generateStaticParams que devolvía los 440 artículos × 3 locales. Deberían aparecer como (HTML estático prerrenderizado). Algo los estaba sobrescribiendo.

El commit culpable, de dos semanas antes:

tsx
// app/layout.tsx — commit a126dc7 "SEO fix: html lang dinámico"
import { headers } from "next/headers";

export default async function RootLayout({ children }) {
  const headersList = await headers();          // ← esta línea
  const lang = headersList.get("x-locale") ?? "es";
  return (
    <html lang={lang}>
      <body>{children}</body>
    </html>
  );
}

La intención era legítima: <html lang="es"> estaba hardcodeado, lo que significaba que las páginas /ja/... se servían con el atributo de idioma equivocado. Google usa lang como señal de ranking para segmentación por locale, así que hacerlo dinámico era trabajo de SEO correcto.

La implementación estaba mal de una forma que el framework no se quejó en absoluto.

Por qué Next.js se vuelve dinámico con headers()

Next.js App Router traza una línea clara: si el árbol de renderizado de una ruta llama a una API dinámicaheaders(), cookies(), draftMode() o lee searchParams — la ruta completa se suscribe a renderizado dinámico. Es un trade-off deliberado: el framework no puede prerrenderizar HTML en tiempo de build si el output depende de datos per-request.

Las consecuencias que se me escaparon al revisar:

  • El root layout envuelve todas las rutas. Una API dinámica en app/layout.tsx cascada hacia abajo. Los 440 artículos, páginas de categoría, páginas de tag — todos dinámicos.
  • Sin error, sin warning. generateStaticParams sigue corriendo en build. Los archivos .next/server/app/**/*.html siguen siendo escritos. Simplemente no se sirven en runtime.
  • Modo dev siempre renderiza dinámicamente. npm run dev no dio señal de que producción se comportara diferente.
  • Los scores de Lighthouse se mantuvieron en los 80. El TTFB solo no hunde Core Web Vitals lo suficiente para alarmarte.

El único síntoma visible era subjetivo: los clicks se sentían pesados.

La solución canónica: mover <html> a [locale]/layout.tsx

El patrón correcto — documentado en el ejemplo oficial de next-intl — es dejar que el segmento de URL lleve el locale, no un header HTTP.

El root layout se vuelve passthrough:

tsx
// app/layout.tsx
import { ReactNode } from "react";

// Como tenemos not-found.tsx en la raíz, el archivo de layout es obligatorio,
// aunque solo pase children a través.
export default function RootLayout({ children }: { children: ReactNode }) {
  return children;
}

El locale layout se hace dueño del tag <html>:

tsx
// app/[locale]/layout.tsx
import { setRequestLocale } from "next-intl/server";
import { hasLocale } from "next-intl";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";

export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  if (!hasLocale(routing.locales, locale)) notFound();

  // Habilita renderizado estático — lee el locale desde URL params, no headers
  setRequestLocale(locale);

  return (
    <html lang={locale}>
      <body>{children}</body>
    </html>
  );
}

La pieza clave es setRequestLocale. Registra el locale con next-intl para el render actual sin leer headers HTTP — lo que significa que la ruta sigue siendo elegible para generación estática.

Un detalle más: cuando el root layout es passthrough, ya no provee <html> y <body> a las rutas hermanas. app/not-found.tsx necesita sus propios wrappers:

tsx
// app/not-found.tsx
export default function NotFound() {
  return (
    <html lang="es">
      <body>{/* UI de 404 */}</body>
    </html>
  );
}

Verificación: los tres comandos que deberían estar en tu checklist de deploy

Estos son los checks que desearía haber corrido en cada PR de rendimiento.

1. Clasificación del build output. Cada ruta de contenido debería mostrar (SSG), no ƒ (Dinámico):

bash
npm run build 2>&1 | grep -E "^(├|│|└)"

Busca la leyenda al final: ● (SSG) prerendered as static HTML (uses generateStaticParams). Si tus rutas de artículos muestran ƒ, algo en el árbol de render está forzando dinámico.

2. TTFB de producción. Después de desplegar, pega la misma URL dos veces desde una ubicación geográficamente relevante:

bash
for i in 1 2 3; do
  curl -s -o /dev/null -w "Run$i TTFB: %{time_starttransfer}s\n" https://tu-sitio.com/alguna-pagina
done

El Run 1 puede ser ~500ms (CDN frío llenándose). Los runs 2+ deberían ser 100ms o menos. Si cada run es 1s+, tu CDN se está saltando.

3. Headers de caché. Una página servida estáticamente debería mostrar x-vercel-cache: HIT tras calentarse:

bash
curl -sI https://tu-sitio.com/alguna-pagina | grep -iE "cache-control|x-vercel-cache|x-vercel-id"

Los anti-patterns a detectar: cache-control conteniendo no-store o private, x-vercel-cache: MISS en requests repetidos, o x-vercel-id mostrando una región de función que no es de edge.

Resultados tras el fix

Archivos cambiados:

  • app/layout.tsx — reducido a return children
  • app/[locale]/layout.tsx — tomó propiedad de <html>, <body>, fonts, scripts de analytics
  • app/not-found.tsx — añadido su propio wrapper <html>/<body>
  • export const dynamic = "force-static" añadido a 13 páginas bajo locale como reaseguro

Transformación del build output. Antes del fix, una sola línea de ruta dinámica:

├ ƒ /[locale]/[category]/[slug]

Después del fix, la misma ruta se expande en cada path prerrenderizado:

├ ● /[locale]/[category]/[slug]
│ ├ /ja/cli/cli-prompt-starship
│ ├ /es/cli/cli-prompt-starship
│ └ [+436 more paths]

TTFB de producción, medido desde Tokio:

Tipo de páginaAntesDespués (cold)Después (warm)
Artículo1257ms590ms100ms
Home1234ms107ms96ms
Categoría950ms585ms90ms

x-vercel-id pasó de hnd1::iad1 (edge en Tokio, función en US East) a solo hnd1 — sin invocación de función en absoluto. El CDN de edge de Vercel solo devuelve el HTML prerrenderizado.

Otros caminos comunes al dinámico accidental

El trap de headers() en el root layout es el más dañino, pero no es la única forma de volverse dinámico silenciosamente. Ojo con:

  • cookies() en layouts compartidos o middleware — misma propagación, mismo silencio
  • searchParams leído a nivel de página — suscribe a la página, no al árbol entero, pero frecuentemente la página es la ruta de mayor valor (home, artículo)
  • fetch(..., { cache: "no-store" }) — una sola llamada sin caché en un layout o página es suficiente
  • Server Actions llamadas durante render — raro en sitios de contenido, pero vale la pena saberlo
  • generateMetadata leyendo APIs dinámicas — cuenta igual que si la página las leyera
  • Importar código que internamente llama APIs dinámicas — la más insidiosa. getLocale() de next-intl/server lee headers() internamente; draftMode() también

Un patrón de auditoría útil: grep -rn "headers()\|cookies()\|draftMode()\|getLocale()" app/ --include="*.tsx". Cada hit dentro de app/layout.tsx o cualquier layout compartido es una potencial regresión de rendimiento.

Preguntas Frecuentes

¿Por qué generateStaticParams + dynamicParams = false no sobrescribió el opt-in dinámico?

No sobrescriben — están subordinados a la detección de APIs dinámicas. La regla de Next.js: si cualquier API dinámica es alcanzable desde el árbol de renderizado en runtime, la ruta renderiza dinámicamente, sin importar lo que diga generateStaticParams. El HTML estático todavía se genera en tiempo de build (por eso existe .next/server/app/**/*.html), pero se trata como inutilizable.

¿export const dynamic = "force-static" soluciona esto por sí solo?

No si una API dinámica se llama realmente en runtime. force-static le dice a Next.js "prometo que esta ruta es estática" — y lanzará error en runtime si llamas a headers() o similar. Así que es una aserción útil (detecta el bug explícitamente), pero la solución real es remover la llamada a la API dinámica misma.

¿Qué pasa si genuinamente necesito el locale en el root layout, no en [locale]/layout?

Usualmente no lo necesitas. Si tienes rutas no localizadas que también necesitan <html> (p.ej., un not-found.tsx a nivel raíz), dales a cada una su propio wrapper <html>, y mantén el root layout como passthrough. Este es el patrón que usa el ejemplo oficial de next-intl.

¿cacheComponents / 'use cache' harán esto obsoleto?

Eventualmente. El próximo modelo de Cache Components de Next.js permite cachear selectivamente partes de una página dinámica. El soporte de next-intl está siendo rastreado en issue #1493 y todavía no ha sido mergeado. Hasta entonces, el fix canónico de arriba es lo que funciona en producción.

¿Cómo se escapó esta regresión de CI?

No disparó ningún check. TypeScript compiló. Lint pasó. Build tuvo éxito. La clasificación de rutas (ƒ vs ) está en el log de build pero es fácil de pasar por alto. El fix para el equipo: un paso de CI que hace grep sobre el output del build y falla si las rutas clave no son . Un guard de una línea que vale la pena tener.

¿Mi sitio usa middleware.ts / proxy.ts — eso también fuerza renderizado dinámico?

Puede. Si tu middleware llama NextResponse.next({ request: { headers: modifiedHeaders } }) — modificando request headers — las rutas emparejadas frecuentemente se fuerzan dinámicas. Middleware que solo redirige, reescribe o setea response cookies no cascada de la misma forma. Audita qué hace tu middleware en los paths calientes.

Conclusión

Un fix de una línea para SEO se convirtió en una regresión de rendimiento de dos semanas porque el framework silenciosamente la expandió de "setear <html lang> por request" a "re-renderizar cada página por request". El costo fue TTFB pasando de un potencial de 100ms a un observado de 1260ms — una ralentización de 12x a través de 440 páginas de artículos.

El fix en sí son tres archivos (root layout, [locale]/layout, not-found). El aprendizaje es de proceso: los tres comandos de verificación de arriba pertenecen al checklist de deploy de cualquier App Router, no solo como reflejo post-mortem.

Si estás corriendo Next.js App Router con next-intl (o cualquier librería i18n que toque headers), abre tu app/layout.tsx raíz ahora mismo y busca cualquier await que resuelva un header, cookie o locale. Esa línea te está costando TTFB que no sabías que estabas pagando.