Enlaces y Navegación

En Next.js, las rutas se renderizan en el servidor por defecto. Esto significa que el cliente a menudo debe esperar una respuesta del servidor antes de que se pueda mostrar una nueva ruta. Next.js incluye integradas la precarga (prefetching), el streaming y las transiciones del lado del cliente, lo que garantiza que la navegación sea rápida y receptiva.

Esta guía explica cómo funciona la navegación en Next.js y cómo puedes optimizarla para rutas dinámicas y redes lentas.

Cómo funciona la navegación

Para entender cómo funciona la navegación en Next.js, es útil familiarizarse con los siguientes conceptos:

Renderizado del servidor (Server Rendering)

En Next.js, los Layouts y Pages son Componentes del Servidor de React (React Server Components) por defecto. En la navegación inicial y subsiguientes, el Payload del Componente del Servidor (Server Component Payload) se genera en el servidor antes de enviarse al cliente.

Hay dos tipos de renderizado del servidor, según cuándo ocurre:

  • Renderizado estático (o prerenderizado): ocurre en tiempo de compilación o durante la revalidación (revalidation) y el resultado se almacena en caché.
  • Renderizado dinámico: ocurre en tiempo de solicitud en respuesta a una petición del cliente.

El inconveniente del renderizado del servidor es que el cliente debe esperar la respuesta del servidor antes de que se pueda mostrar la nueva ruta. Next.js aborda este retraso mediante la precarga (prefetching) de rutas que es probable que el usuario visite y realizando transiciones del lado del cliente (client-side transitions).

Nota útil: También se genera HTML para la visita inicial.

Precarga (Prefetching)

La precarga es el proceso de cargar una ruta en segundo plano antes de que el usuario navegue a ella. Esto hace que la navegación entre rutas en tu aplicación se sienta instantánea, porque cuando el usuario hace clic en un enlace, los datos para renderizar la siguiente ruta ya están disponibles en el cliente.

Next.js precarga automáticamente las rutas enlazadas con el componente <Link> cuando entran en el viewport del usuario.

import Link from 'next/link'

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <nav>
          {/* Precargado cuando el enlace se pasa con el cursor o entra en el viewport */}
          <Link href="/blog">Blog</Link>
          {/* Sin precarga */}
          <a href="/contact">Contacto</a>
        </nav>
        {children}
      </body>
    </html>
  )
}
import Link from 'next/link'

export default function Layout() {
  return (
    <html>
      <body>
        <nav>
          {/* Precargado cuando el enlace se pasa con el cursor o entra en el viewport */}
          <Link href="/blog">Blog</Link>
          {/* Sin precarga */}
          <a href="/contact">Contacto</a>
        </nav>
        {children}
      </body>
    </html>
  )
}

La cantidad de la ruta que se precarga depende de si es estática o dinámica:

  • Ruta estática: se precarga toda la ruta.
  • Ruta dinámica: se omite la precarga o se precarga parcialmente si existe loading.tsx.

Al omitir o precargar parcialmente rutas dinámicas, Next.js evita trabajo innecesario en el servidor para rutas que los usuarios podrían nunca visitar. Sin embargo, esperar una respuesta del servidor antes de la navegación puede dar a los usuarios la impresión de que la aplicación no responde.

Renderizado del servidor sin streaming

Para mejorar la experiencia de navegación a rutas dinámicas, puedes usar streaming.

Streaming

El streaming permite que el servidor envíe partes de una ruta dinámica al cliente tan pronto como estén listas, en lugar de esperar a que se renderice toda la ruta. Esto significa que los usuarios ven algo antes, incluso si partes de la página aún se están cargando.

Para rutas dinámicas, significa que pueden precargarse parcialmente. Es decir, los diseños compartidos y los esqueletos de carga pueden solicitarse con anticipación.

Cómo funciona el renderizado del servidor con streaming

Para usar streaming, crea un loading.tsx en tu carpeta de ruta:

Archivo especial loading.js
export default function Loading() {
  // Añade una UI de respaldo que se mostrará mientras la ruta se carga.
  return <LoadingSkeleton />
}
export default function Loading() {
  // Añade una UI de respaldo que se mostrará mientras la ruta se carga.
  return <LoadingSkeleton />
}

Internamente, Next.js envolverá automáticamente los contenidos de page.tsx en un límite <Suspense>. La UI de respaldo precargada se mostrará mientras la ruta se carga y se reemplazará por el contenido real una vez listo.

Nota útil: También puedes usar <Suspense> para crear UI de carga para componentes anidados.

Beneficios de loading.tsx:

  • Navegación inmediata y retroalimentación visual para el usuario.
  • Los diseños compartidos permanecen interactivos y la navegación es interrumpible.
  • Mejores Core Web Vitals: TTFB, FCP y TTI.

Para mejorar aún más la experiencia de navegación, Next.js realiza una transición del lado del cliente (client-side transition) con el componente <Link>.

Transiciones del lado del cliente (Client-side transitions)

Tradicionalmente, la navegación a una página renderizada en el servidor desencadena una carga completa de la página. Esto borra el estado, restablece la posición de desplazamiento y bloquea la interactividad.

Next.js evita esto con transiciones del lado del cliente usando el componente <Link>. En lugar de recargar la página, actualiza el contenido dinámicamente al:

  • Mantener cualquier diseño compartido y UI.
  • Reemplazar la página actual con el estado de carga precargado o una nueva página si está disponible.

Las transiciones del lado del cliente son lo que hace que las aplicaciones renderizadas en el servidor se sientan como aplicaciones renderizadas en el cliente. Y cuando se combinan con precarga (prefetching) y streaming, permiten transiciones rápidas, incluso para rutas dinámicas.

¿Qué puede hacer que las transiciones sean lentas?

Estas optimizaciones de Next.js hacen que la navegación sea rápida y receptiva. Sin embargo, bajo ciertas condiciones, las transiciones aún pueden sentirse lentas. Aquí hay algunas causas comunes y cómo mejorar la experiencia del usuario:

Rutas dinámicas sin loading.tsx

Al navegar a una ruta dinámica, el cliente debe esperar la respuesta del servidor antes de mostrar el resultado. Esto puede dar a los usuarios la impresión de que la aplicación no responde.

Recomendamos añadir loading.tsx a las rutas dinámicas para habilitar la precarga parcial, desencadenar la navegación inmediata y mostrar una UI de carga mientras se renderiza la ruta.

export default function Loading() {
  return <LoadingSkeleton />
}
export default function Loading() {
  return <LoadingSkeleton />
}

Nota útil: En modo desarrollo, puedes usar las Devtools de Next.js para identificar si la ruta es estática o dinámica. Consulta devIndicators para más información.

Segmentos dinámicos sin generateStaticParams

Si un segmento dinámico podría prerenderizarse pero no lo está porque falta generateStaticParams, la ruta recurrirá al renderizado dinámico en tiempo de solicitud.

Asegúrate de que la ruta se genere estáticamente en tiempo de compilación añadiendo generateStaticParams:

export async function generateStaticParams() {
  const posts = await fetch('https://.../posts').then((res) => res.json())

  return posts.map((post) => ({
    slug: post.slug,
  }))
}

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  // ...
}
export async function generateStaticParams() {
  const posts = await fetch('https://.../posts').then((res) => res.json())

  return posts.map((post) => ({
    slug: post.slug,
  }))

export default async function Page({ params }) {
  const { slug } = await params
  // ...
}

Redes lentas

En redes lentas o inestables, la precarga puede no terminar antes de que el usuario haga clic en un enlace. Esto puede afectar tanto a rutas estáticas como dinámicas. En estos casos, el respaldo loading.js puede no aparecer inmediatamente porque aún no se ha precargado.

Para mejorar el rendimiento percibido, puedes usar el hook useLinkStatus para mostrar retroalimentación visual en línea al usuario (como spinners o destellos de texto en el enlace) mientras una transición está en progreso.

'use client'

import { useLinkStatus } from 'next/link'

export default function LoadingIndicator() {
  const { pending } = useLinkStatus()
  return pending ? (
    <div role="status" aria-label="Loading" className="spinner" />
  ) : null
}
'use client'

import { useLinkStatus } from 'next/link'

export default function LoadingIndicator() {
  const { pending } = useLinkStatus()
  return pending ? (
    <div role="status" aria-label="Loading" className="spinner" />
  ) : null
}

Puedes "debounce" el indicador de carga añadiendo un retraso inicial en la animación (por ejemplo, 100ms) y comenzando la animación como invisible (por ejemplo, opacity: 0). Esto significa que el indicador de carga solo se mostrará si la navegación tarda más que el retraso especificado.

.spinner {
  /* ... */
  opacity: 0;
  animation:
    fadeIn 500ms 100ms forwards,
    rotate 1s linear infinite;
}

@keyframes fadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes rotate {
  to {
    transform: rotate(360deg);
  }
}

Nota útil: Puedes usar otros patrones de retroalimentación visual como una barra de progreso. Ve un ejemplo aquí.

Desactivar la precarga

Puedes optar por no usar la precarga estableciendo la prop prefetch en false en el componente <Link>. Esto es útil para evitar el uso innecesario de recursos al renderizar grandes listas de enlaces (por ejemplo, una tabla de desplazamiento infinito).

<Link prefetch={false} href="/blog">
  Blog
</Link>

Sin embargo, desactivar la precarga tiene sus contrapartidas:

  • Rutas estáticas: solo se cargarán cuando el usuario haga clic en el enlace.
  • Rutas dinámicas: necesitarán renderizarse primero en el servidor antes de que el cliente pueda navegar a ellas.

Para reducir el uso de recursos sin desactivar completamente la precarga, puedes precargar solo al pasar el cursor. Esto limita la precarga a rutas que es más probable que el usuario visite, en lugar de todos los enlaces en el viewport.

'use client'

import Link from 'next/link'
import { useState } from 'react'

function HoverPrefetchLink({
  href,
  children,
}: {
  href: string
  children: React.ReactNode
}) {
  const [active, setActive] = useState(false)

  return (
    <Link
      href={href}
      prefetch={active ? null : false}
      onMouseEnter={() => setActive(true)}
    >
      {children}
    </Link>
  )
}
'use client'

import Link from 'next/link'
import { useState } from 'react'

function HoverPrefetchLink({ href, children }) {
  const [active, setActive] = useState(false)

  return (
    <Link
      href={href}
      prefetch={active ? null : false}
      onMouseEnter={() => setActive(true)}
    >
      {children}
    </Link>
  )
}

Hidratación no completada

<Link> es un Componente del Cliente y debe hidratarse antes de poder precargar rutas. En la visita inicial, grandes paquetes de JavaScript pueden retrasar la hidratación, impidiendo que la precarga comience de inmediato.

React mitiga esto con Hidratación Selectiva y puedes mejorarlo aún más:

Ejemplos

API de Historial Nativo

Next.js te permite usar los métodos nativos window.history.pushState y window.history.replaceState para actualizar la pila de historial del navegador sin recargar la página.

Las llamadas pushState y replaceState se integran con el Enrutador de Next.js, permitiéndote sincronizar con usePathname y useSearchParams.

window.history.pushState

Úsalo para añadir una nueva entrada a la pila de historial del navegador. El usuario puede navegar de vuelta al estado anterior. Por ejemplo, para ordenar una lista de productos:

'use client'

import { useSearchParams } from 'next/navigation'

export default function SortProducts() {
  const searchParams = useSearchParams()

  function updateSorting(sortOrder: string) {
    const params = new URLSearchParams(searchParams.toString())
    params.set('sort', sortOrder)
    window.history.pushState(null, '', `?${params.toString()}`)
  }

  return (
    <>
      <button onClick={() => updateSorting('asc')}>Ordenar Ascendente</button>
      <button onClick={() => updateSorting('desc')}>Ordenar Descendente</button>
    </>
  )
}
'use client'

import { useSearchParams } from 'next/navigation'

export default function SortProducts() {
  const searchParams = useSearchParams()

  function updateSorting(sortOrder) {
    const params = new URLSearchParams(searchParams.toString())
    params.set('sort', sortOrder)
    window.history.pushState(null, '', `?${params.toString()}`)
  }

  return (
    <>
      <button onClick={() => updateSorting('asc')}>Ordenar Ascendente</button>
      <button onClick={() => updateSorting('desc')}>Ordenar Descendente</button>
    </>
  )
}

window.history.replaceState

Úselo para reemplazar la entrada actual en la pila de historial del navegador. El usuario no podrá navegar de vuelta al estado anterior. Por ejemplo, para cambiar la configuración regional (locale) de la aplicación:

'use client'

import { usePathname } from 'next/navigation'

export function LocaleSwitcher() {
  const pathname = usePathname()

  function switchLocale(locale: string) {
    // ej. '/en/about' o '/fr/contact'
    const newPath = `/${locale}${pathname}`
    window.history.replaceState(null, '', newPath)
  }

  return (
    <>
      <button onClick={() => switchLocale('en')}>English</button>
      <button onClick={() => switchLocale('fr')}>French</button>
    </>
  )
}
'use client'

import { usePathname } from 'next/navigation'

export function LocaleSwitcher() {
  const pathname = usePathname()

  function switchLocale(locale) {
    // ej. '/en/about' o '/fr/contact'
    const newPath = `/${locale}${pathname}`
    window.history.replaceState(null, '', newPath)
  }

  return (
    <>
      <button onClick={() => switchLocale('en')}>English</button>
      <button onClick={() => switchLocale('fr')}>French</button>
    </>
  )
}