Next.jsReactApp RouterTypeScript

Diferencias entre App Router y Pages Router en Next.js

8 min read
Vistas

Guía completa sobre las diferencias entre App Router (app/) y Pages Router (pages/) en Next.js. Cuál usar, cuándo migrar y ejemplos prácticos.

Compartir

Next.js 13 introdujo el App Router (app/ directory), un cambio fundamental en cómo estructuramos aplicaciones Next.js. Pero el Pages Router (pages/ directory) sigue siendo totalmente válido y soportado. ¿Cuál usar? ¿Vale la pena migrar? Te lo explico todo.

Tabla Comparativa Rápida

CaracterísticaPages Router (pages/)App Router (app/)
LanzamientoNext.js 9 (2019)Next.js 13 (2022)
EstadoEstable, mantenidoEstable desde Next.js 14
ComponentesSolo Client ComponentsServer + Client Components
Layouts_app.js + _document.jsLayouts anidados nativos
Data FetchinggetServerSideProps, getStaticPropsasync components, fetch
Loading StatesManualloading.js automático
Error Handling_error.js globalerror.js por ruta
StreamingNoSí (RSC + Suspense)

Pages Router: El Clásico

Estructura de archivos

pages/
├── _app.js          # Layout global
├── _document.js     # HTML document
├── index.js         # → /
├── about.js         # → /about
├── blog/
│   ├── index.js     # → /blog
│   └── [slug].js    # → /blog/post-1
└── api/
    └── hello.js     # → /api/hello

Ejemplo: Página con Data Fetching

// pages/blog/[slug].js
export default function BlogPost({ post }) {
  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </div>
  );
}

// Server-side rendering
export async function getServerSideProps({ params }) {
  const post = await fetch(`https://api.com/posts/${params.slug}`).then((r) => r.json());
  return { props: { post } };
}

// O Static Generation
export async function getStaticProps({ params }) {
  const post = await fetch(`https://api.com/posts/${params.slug}`).then((r) => r.json());
  return { props: { post } };
}

export async function getStaticPaths() {
  const posts = await fetch('https://api.com/posts').then((r) => r.json());
  return {
    paths: posts.map((p) => ({ params: { slug: p.slug } })),
    fallback: false,
  };
}
Todo es Client Component

En Pages Router, todos los componentes son Client Components por defecto. El código corre en el cliente después de la hidratación.

Layout Global con _app.js

// pages/_app.js
import '../styles/globals.css';
import Navbar from '../components/Navbar';
import Footer from '../components/Footer';

export default function App({ Component, pageProps }) {
  return (
    <>
      <Navbar />
      <Component {...pageProps} />
      <Footer />
    </>
  );
}

Problema: Solo hay un layout global. Si quieres layouts diferentes por sección (blog vs dashboard), necesitas lógica condicional.

App Router: La Nueva Era

Estructura de archivos

app/
├── layout.js        # Root layout
├── page.js          # → /
├── loading.js       # Loading UI
├── error.js         # Error handling
├── about/
│   └── page.js      # → /about
├── blog/
│   ├── layout.js    # Layout solo para /blog/*
│   ├── page.js      # → /blog
│   └── [slug]/
│       ├── page.js  # → /blog/post-1
│       └── loading.js
└── api/
    └── hello/
        └── route.js # → /api/hello

Server Components por Defecto

// app/blog/[slug]/page.js
// Este es un SERVER COMPONENT (corre en el servidor)
export default async function BlogPost({ params }) {
  const { slug } = await params;

  // Fetch directo en el componente, sin getServerSideProps
  const post = await fetch(`https://api.com/posts/${slug}`, {
    next: { revalidate: 60 }, // ISR automático
  }).then((r) => r.json());

  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </div>
  );
}

// generateStaticParams reemplaza getStaticPaths
export async function generateStaticParams() {
  const posts = await fetch('https://api.com/posts').then((r) => r.json());
  return posts.map((p) => ({ slug: p.slug }));
}
¡Fetch directo en el componente!

No más getServerSideProps ni getStaticProps. Los Server Components pueden hacer fetch directamente, y Next.js lo optimiza automáticamente.

Layouts Anidados

// app/layout.js (root)
export default function RootLayout({ children }) {
  return (
    <html lang="es">
      <body>
        <Header />
        {children}
        <Footer />
      </body>
    </html>
  );
}

// app/blog/layout.js (solo para /blog/*)
export default function BlogLayout({ children }) {
  return (
    <div className="blog-container">
      <aside>
        <h3>Artículos Recientes</h3>
        {/* Sidebar */}
      </aside>
      <main>{children}</main>
    </div>
  );
}

Ventaja: Cada sección puede tener su propio layout, y se anidan automáticamente.

Loading States Automáticos

// app/blog/loading.js
export default function Loading() {
  return (
    <div className="spinner">
      <p>Cargando artículos...</p>
    </div>
  );
}

Next.js muestra este componente automáticamente mientras page.js hace fetch de datos. ¡Sin boilerplate!

Error Handling por Ruta

// app/blog/error.js
'use client'; // Error boundaries deben ser Client Components

export default function Error({ error, reset }) {
  return (
    <div>
      <h2>Algo salió mal!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Reintentar</button>
    </div>
  );
}

Cada ruta puede tener su propio manejo de errores. No más _error.js global.

Client Components en App Router

Si necesitas interactividad (hooks, eventos, etc.), usa 'use client':

// app/components/Counter.js
'use client';

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  return <button onClick={() => setCount(count + 1)}>Clicks: {count}</button>;
}
Cuándo usar 'use client'
  • Cuando usas hooks (useState, useEffect, etc.) - Cuando manejas eventos (onClick, onChange, etc.) - Cuando usas APIs del navegador (window, localStorage, etc.) - Cuando usas librerías que dependen del cliente

Data Fetching: Comparación

Pages Router

// SSR
export async function getServerSideProps() {
  const data = await fetch('https://api.com/data');
  return { props: { data } };
}

// SSG
export async function getStaticProps() {
  const data = await fetch('https://api.com/data');
  return { props: { data }, revalidate: 60 }; // ISR
}

App Router

// SSR (por defecto)
async function getData() {
  const res = await fetch('https://api.com/data', { cache: 'no-store' });
  return res.json();
}

export default async function Page() {
  const data = await getData();
  return <div>{data.title}</div>;
}

// SSG
async function getData() {
  const res = await fetch('https://api.com/data', { cache: 'force-cache' });
  return res.json();
}

// ISR (Incremental Static Regeneration)
async function getData() {
  const res = await fetch('https://api.com/data', {
    next: { revalidate: 60 }, // Revalida cada 60 segundos
  });
  return res.json();
}

Streaming y Suspense

App Router soporta React Server Components + Suspense, permitiendo streaming:

// app/dashboard/page.js
import { Suspense } from 'react';

async function SlowComponent() {
  const data = await fetch('https://slow-api.com/data');
  return <div>{/* renderiza data */}</div>;
}

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      {/* Esto se renderiza inmediatamente */}
      <FastContent />

      {/* Esto hace streaming cuando está listo */}
      <Suspense fallback={<Skeleton />}>
        <SlowComponent />
      </Suspense>
    </div>
  );
}

El HTML inicial se envía de inmediato, y los componentes lentos hacen "streaming" cuando están listos. ¡Mejor UX!

Metadata API

Pages Router

// pages/blog/[slug].js
import Head from 'next/head';

export default function Post({ post }) {
  return (
    <>
      <Head>
        <title>{post.title} | Mi Blog</title>
        <meta name="description" content={post.summary} />
      </Head>
      <article>{/* contenido */}</article>
    </>
  );
}

App Router

// app/blog/[slug]/page.js
export async function generateMetadata({ params }) {
  const post = await fetch(`https://api.com/posts/${params.slug}`).then((r) => r.json());

  return {
    title: `${post.title} | Mi Blog`,
    description: post.summary,
    openGraph: {
      title: post.title,
      description: post.summary,
      images: [post.image],
    },
  };
}

export default function Post({ params }) {
  // ...
}

Mucho más limpio y con type safety.

¿Cuál Usar?

Usa Pages Router si:

  • ✅ Tienes un proyecto existente que funciona bien
  • ✅ Tu equipo no está familiarizado con Server Components
  • ✅ Usas librerías que no son compatibles con RSC aún
  • ✅ Necesitas estabilidad probada en producción (aunque App Router ya es estable)

Usa App Router si:

  • ✅ Empiezas un proyecto nuevo
  • ✅ Quieres mejor rendimiento (Server Components reducen JavaScript)
  • ✅ Necesitas layouts complejos y anidados
  • ✅ Quieres streaming y loading states automáticos
  • ✅ Valoras DX moderna (fetch directo, metadata API, etc.)
Mi recomendación

Proyectos nuevos → App Router. Es el futuro de Next.js y tiene mejor DX.

Proyectos existentes → Pages Router hasta que tengas una razón específica para migrar (nueva funcionalidad, refactor mayor, etc.). No migres solo por migrar.

Migración Gradual

¡Buenas noticias! Puedes usar ambos al mismo tiempo:

mi-app/
├── app/
│   └── dashboard/    # Nuevas rutas en App Router
│       └── page.js
└── pages/
    ├── index.js      # Rutas existentes en Pages Router
    └── about.js

Next.js prioriza app/ sobre pages/ si ambos definen la misma ruta.

Conclusión

  • Pages Router: Maduro, estable, familiar. Perfecto para proyectos existentes.
  • App Router: Moderno, eficiente, con mejor DX. Ideal para proyectos nuevos.

Ambos están soportados y no hay prisa por migrar. Pero si empiezas algo nuevo, App Router te dará ventajas significativas en rendimiento y experiencia de desarrollo.

¿Listo para el futuro de Next.js? 🚀

Recursos


¿Te gustó este artículo?

Artículos relacionados