Next.js se ha convertido en el framework de referencia para construir aplicaciones web con React en producción. Con el App Router estable, los Server Components como paradigma por defecto, Server Actions para mutaciones de datos y un sistema de caché sofisticado, Next.js 15 es una plataforma completa de desarrollo web. Esta guía te lleva desde cero hasta entender profundamente cómo funciona todo.

¿Qué hace especial a Next.js?

Next.js resuelve los problemas principales de crear una aplicación React desde cero: routing, rendering del lado del servidor (SSR), generación de páginas estáticas (SSG), optimización de imágenes, gestión de fuentes, configuración de TypeScript, y despliegue. Todo esto con convenciones inteligentes que minimizan la configuración.

La diferencia fundamental entre React puro y Next.js es que Next.js puede renderizar componentes en el servidor, reduciendo el JavaScript que se envía al cliente y mejorando el SEO y el rendimiento percibido de la aplicación.

Crear un proyecto Next.js 15

npx create-next-app@latest mi-app

# El asistente preguntará:
# TypeScript: Sí
# ESLint: Sí
# Tailwind CSS: Sí (recomendado)
# src/ directory: Sí (mejor organización)
# App Router: Sí (el nuevo sistema de routing)
# Alias de importación: Sí (@/)

cd mi-app
npm run dev

La estructura del proyecto con App Router

src/
├── app/
│   ├── layout.tsx          ← Layout raíz (siempre presente)
│   ├── page.tsx            ← Página: /
│   ├── globals.css
│   ├── blog/
│   │   ├── page.tsx        ← Página: /blog
│   │   └── [slug]/
│   │       └── page.tsx    ← Página dinámica: /blog/mi-articulo
│   ├── productos/
│   │   ├── layout.tsx      ← Layout para /productos y sus hijos
│   │   ├── page.tsx        ← Página: /productos
│   │   └── [id]/
│   │       ├── page.tsx    ← Página: /productos/42
│   │       └── loading.tsx ← UI de carga para esta ruta
│   ├── api/
│   │   └── usuarios/
│   │       └── route.ts    ← API endpoint: /api/usuarios
│   └── (auth)/             ← Grupo de rutas (no afecta a la URL)
│       ├── login/
│       │   └── page.tsx    ← Página: /login
│       └── registro/
│           └── page.tsx    ← Página: /registro
├── components/
├── lib/
└── types/

Server Components vs Client Components

Este es el concepto más importante de entender en Next.js moderno. Por defecto, todos los componentes en el App Router son Server Components: se renderizan en el servidor, tienen acceso directo a bases de datos y APIs, y no incluyen JavaScript en el bundle del cliente. Para componentes que necesitan interactividad (useState, useEffect, eventos del navegador), debes marcarlos como Client Components con la directiva 'use client'.

// app/blog/page.tsx - Server Component (por defecto)
// Puede ser async, puede hacer fetch directamente
import { db } from '@/lib/db'

export default async function BlogPage() {
  // Fetch de datos directamente en el servidor - sin useEffect, sin loading states
  const articulos = await db.articulo.findMany({
    orderBy: { creadoEn: 'desc' },
    take: 10
  })

  return (
    <main>
      <h1>Blog</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {articulos.map(articulo => (
          <TarjetaArticulo key={articulo.id} articulo={articulo} />
        ))}
      </div>
    </main>
  )
}

// components/BotonLike.tsx - Client Component (necesita estado)
'use client'

import { useState } from 'react'

interface Props {
  articuloId: number
  likesIniciales: number
}

export function BotonLike({ articuloId, likesIniciales }: Props) {
  const [likes, setLikes] = useState(likesIniciales)
  const [dado, setDado] = useState(false)

  async function darLike() {
    if (dado) return
    setDado(true)
    setLikes(l => l + 1)
    await fetch(`/api/likes/${articuloId}`, { method: 'POST' })
  }

  return (
    <button onClick={darLike} disabled={dado}>
      ❤️ {likes}
    </button>
  )
}

Routing dinámico y parámetros

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { db } from '@/lib/db'

interface Props {
  params: Promise<{ slug: string }>
  searchParams: Promise<{ [key: string]: string | undefined }>
}

// Generar rutas estáticas en build time (SSG)
export async function generateStaticParams() {
  const articulos = await db.articulo.findMany({ select: { slug: true } })
  return articulos.map(a => ({ slug: a.slug }))
}

// Metadatos dinámicos para SEO
export async function generateMetadata({ params }: Props) {
  const { slug } = await params
  const articulo = await db.articulo.findUnique({ where: { slug } })
  if (!articulo) return { title: 'No encontrado' }

  return {
    title: articulo.titulo,
    description: articulo.resumen,
    openGraph: {
      title: articulo.titulo,
      description: articulo.resumen,
      images: [articulo.imagenPortada]
    }
  }
}

export default async function ArticuloPage({ params }: Props) {
  const { slug } = await params
  const articulo = await db.articulo.findUnique({ where: { slug } })

  if (!articulo) notFound()  // Muestra la página 404

  return (
    <article>
      <h1>{articulo.titulo}</h1>
      <div dangerouslySetInnerHTML={{ __html: articulo.contenidoHtml }} />
    </article>
  )
}

Server Actions: mutaciones de datos sin API endpoints

Las Server Actions son funciones que se ejecutan en el servidor pero se pueden llamar desde el cliente, como si fueran funciones locales. Eliminan la necesidad de crear endpoints API para operaciones de escritura simples.

// actions/comentarios.ts
'use server'

import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
import { redirect } from 'next/navigation'

export async function crearComentario(articuloId: number, formData: FormData) {
  const contenido = formData.get('contenido') as string
  const autor = formData.get('autor') as string

  if (!contenido || contenido.length < 10) {
    return { error: 'El comentario debe tener al menos 10 caracteres' }
  }

  await db.comentario.create({
    data: { contenido, autor, articuloId }
  })

  // Invalidar la caché de la página del artículo
  revalidatePath(`/blog/${articuloId}`)

  return { exito: true }
}

export async function eliminarComentario(id: number) {
  await db.comentario.delete({ where: { id } })
  revalidatePath('/blog')
}

// Usar en un componente (puede ser Server o Client Component)
// En Server Component: directamente en el action del form
// <form action={crearComentario.bind(null, articuloId)}>
//   <textarea name="contenido" />
//   <input name="autor" />
//   <button type="submit">Comentar</button>
// </form>

// En Client Component:
// 'use client'
// import { useActionState } from 'react'
// const [estado, accion, pendiente] = useActionState(crearComentario.bind(null, articuloId), null)

Route Handlers: crear API endpoints

// app/api/productos/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams
  const categoria = searchParams.get('categoria')
  const pagina = parseInt(searchParams.get('pagina') || '1')
  const limite = 20

  const productos = await db.producto.findMany({
    where: categoria ? { categoria } : undefined,
    skip: (pagina - 1) * limite,
    take: limite,
    orderBy: { creadoEn: 'desc' }
  })

  return NextResponse.json({ productos, pagina, limite })
}

export async function POST(request: NextRequest) {
  const body = await request.json()
  const { nombre, precio, categoria } = body

  if (!nombre || !precio) {
    return NextResponse.json({ error: 'Datos incompletos' }, { status: 400 })
  }

  const producto = await db.producto.create({
    data: { nombre, precio, categoria }
  })

  return NextResponse.json(producto, { status: 201 })
}

// app/api/productos/[id]/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const producto = await db.producto.findUnique({ where: { id: parseInt(id) } })
  if (!producto) return NextResponse.json({ error: 'No encontrado' }, { status: 404 })
  return NextResponse.json(producto)
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  await db.producto.delete({ where: { id: parseInt(id) } })
  return new NextResponse(null, { status: 204 })
}

Caché y revalidación: entender el sistema de caché de Next.js

El sistema de caché de Next.js 15 es potente pero requiere entenderse bien para no llevarse sorpresas. En Next.js 15, el comportamiento por defecto de las peticiones fetch cambió: ya no están cacheadas por defecto, debes optar explícitamente.

// Sin caché (por defecto en Next.js 15): siempre fetch fresco
const data = await fetch('/api/datos')

// Con caché (equivalente a SSG): se cachea indefinidamente
const data = await fetch('/api/datos', { cache: 'force-cache' })

// Revalidación por tiempo: ISR (Incremental Static Regeneration)
const data = await fetch('/api/datos', {
  next: { revalidate: 3600 }  // Revalidar cada hora
})

// Revalidación por tags: invalidar manualmente
const data = await fetch('/api/productos', {
  next: { tags: ['productos'] }
})

// En una Server Action o Route Handler, invalidar por tag:
import { revalidateTag } from 'next/cache'
revalidateTag('productos')  // Invalida todo lo que tenga el tag 'productos'

// Configurar revalidación a nivel de segmento de ruta
// export const revalidate = 3600  // Revalidar la página cada hora
// export const dynamic = 'force-dynamic'  // Siempre renderizar dinámicamente

Optimización de imágenes con next/image

import Image from 'next/image'

// Imagen con tamaño fijo
<Image
  src="/mi-foto.jpg"
  alt="Descripción de la foto"
  width={800}
  height={600}
  priority  // Cargar con alta prioridad (above the fold)
/>

// Imagen que llena su contenedor (requiere contenedor con posición relativa)
<div className="relative h-64 w-full">
  <Image
    src="/banner.jpg"
    alt="Banner"
    fill
    sizes="(max-width: 768px) 100vw, 50vw"
    className="object-cover"
  />
</div>

// Imagen de fuente externa (requiere config en next.config.ts)
<Image
  src="https://images.unsplash.com/photo-xxx"
  alt="Foto de Unsplash"
  width={400}
  height={300}
/>

// next.config.ts
const nextConfig = {
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'images.unsplash.com' }
    ]
  }
}

Middleware: ejecutar código antes de las rutas

// middleware.ts (en la raíz del proyecto)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const token = request.cookies.get('token')?.value
  const { pathname } = request.nextUrl

  // Proteger rutas de admin
  if (pathname.startsWith('/admin')) {
    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url))
    }
  }

  // Redirigir rutas antiguas
  if (pathname === '/blog-viejo') {
    return NextResponse.redirect(new URL('/blog', request.url))
  }

  // Añadir headers personalizados
  const response = NextResponse.next()
  response.headers.set('X-Custom-Header', 'mi-valor')

  return response
}

// Solo ejecutar en estas rutas
export const config = {
  matcher: ['/admin/:path*', '/blog-viejo', '/api/:path*']
}

Next.js 15 es la opción más madura y completa para construir aplicaciones web con React en producción. El modelo mental de Server Components tarda un poco en asentarse, especialmente la frontera entre servidor y cliente, pero una vez que lo entiendes la productividad se dispara. Si tu próximo proyecto es una aplicación web pública que necesita SEO, rendimiento y escalabilidad, Next.js debería ser tu primera opción.