Llevaba tiempo usando Next.js con Pages Router y cuando migré a App Router me costó entender el cambio de paradigma. Aquí te explico lo que aprendí, sin rodeos.
El Pages Router funcionaba con archivos en /pages. Cada archivo era una ruta y los datos se obtenían con getServerSideProps o getStaticProps.
App Router cambia todo:
/appasync/awaitapp/
├── layout.tsx ← layout raíz (obligatorio)
├── page.tsx ← ruta /
├── about/
│ └── page.tsx ← ruta /about
└── blog/
├── page.tsx ← ruta /blog
└── [slug]/
└── page.tsx ← ruta /blog/:slug
Cada carpeta puede tener su propio layout.tsx, loading.tsx y error.tsx.
Este es el cambio más importante. Por defecto, todos los componentes son Server Components.
// Server Component — se ejecuta en el servidor
// Puede hacer fetch directo, acceder a DB, etc.
export default async function ProductPage() {
const products = await fetchProducts() // llamada directa, sin useEffect
return <ProductList products={products} />
}
Si necesitas interactividad (estado, eventos, hooks), añades 'use client' al principio:
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
)
}
Regla práctica: mantén los Client Components lo más abajo posible en el árbol. Cuantos menos, mejor rendimiento.
Adiós a getServerSideProps. Ahora haces fetch directamente:
// app/blog/page.tsx
export default async function BlogPage() {
const posts = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 } // revalida cada hora
}).then(res => res.json())
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
Para datos estáticos (como mi blog con MDX), simplemente llamo a la función:
export default function BlogPage() {
const posts = getAllPosts() // lee archivos MDX en build time
return <PostList posts={posts} />
}
Crea un archivo loading.tsx en cualquier carpeta y Next.js lo muestra automáticamente mientras carga la página:
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/2" />
<div className="h-4 bg-gray-200 rounded w-full" />
<div className="h-4 bg-gray-200 rounded w-3/4" />
</div>
)
}
No necesitas ningún estado de carga manual. Next.js usa Suspense internamente.
Para generar páginas estáticas con rutas dinámicas (como posts de blog):
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = getAllPosts()
return posts.map(post => ({ slug: post.slug }))
}
export default async function PostPage({
params
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = getPostBySlug(slug)
if (!post) notFound()
return <PostContent post={post} />
}
Next.js genera un HTML por cada slug en build time. Resultado: carga instantánea y buen SEO.
Exporta un objeto metadata o una función generateMetadata:
// Estático
export const metadata = {
title: 'Mi Blog',
description: 'Artículos sobre desarrollo web'
}
// Dinámico por página
export async function generateMetadata({ params }) {
const post = getPostBySlug(params.slug)
return {
title: post.title,
description: post.description,
openGraph: { type: 'article' }
}
}
Los layouts persisten entre navegaciones. Si tienes un layout con estado, ese estado no se resetea al navegar. Útil para navbars, sidebars y temas.
No puedes importar Server Components desde Client Components (solo al revés). Si tienes un árbol mixto, pasa los Server Components como children.
// ✅ Correcto
export default function ClientWrapper({ children }) {
return <div onClick={...}>{children}</div>
}
// En el servidor:
<ClientWrapper>
<ServerComponent /> {/* server component como hijo */}
</ClientWrapper>
Después de usarlo en este portfolio y en otros proyectos, sí. Las ventajas son claras:
La curva de aprendizaje existe, pero una vez que interiorices el modelo mental de Server vs Client, todo encaja.
Si tienes dudas sobre algún concepto, escríbeme.