Volver a artículos
Desarrollo de Software

Next.js 14: App Router y Server Components

Domina Next.js 14 con App Router: Server Components, Client Components, routing avanzado, data fetching moderno y optimización de metadata.

Academia Tooldata
30 de agosto de 2026
5 min de lectura
nextjsreactserver-componentsapp-router

Introducción a Next.js 14

Next.js es un framework de React para producción que añade funcionalidades críticas como:

  • Server-Side Rendering (SSR)
  • Static Site Generation (SSG)
  • API Routes integradas
  • File-based routing
  • Optimización automática de imágenes y fuentes
  • App Router (nuevo paradigma desde Next.js 13)

Novedad en Next.js 14: Turbopack (más rápido), Server Actions estables, mejoras en caching y metadatos.

Next.js vs Create React App

Característica Create React App Next.js
Rendering Solo client-side SSR, SSG, ISR, CSR
Routing Requiere React Router File-based, integrado
SEO Limitado Excelente out-of-the-box
Performance Manual Optimizado automáticamente
API Backend Requiere servidor separado API Routes integradas
Deploy Manual Optimizado para Vercel

Configuración Inicial

# Crear nuevo proyecto Next.js 14
npx create-next-app@latest mi-app-nextjs

# Opciones recomendadas:
# ✅ TypeScript: Yes (opcional pero recomendado)
# ✅ ESLint: Yes
# ✅ Tailwind CSS: Yes
# ✅ src/ directory: Yes
# ✅ App Router: Yes (IMPORTANTE)
# ✅ Turbopack: Yes

cd mi-app-nextjs
npm run dev

Estructura del proyecto con App Router:

mi-app-nextjs/
├── src/
│   └── app/
│       ├── layout.tsx          # Layout raíz
│       ├── page.tsx            # Página principal (/)
│       ├── loading.tsx         # Loading UI
│       ├── error.tsx           # Error UI
│       ├── not-found.tsx       # 404 página
│       ├── about/
│       │   └── page.tsx        # /about
│       ├── blog/
│       │   ├── page.tsx        # /blog
│       │   └── [slug]/
│       │       └── page.tsx    # /blog/[slug]
│       └── api/
│           └── hello/
│               └── route.ts    # API endpoint
├── public/                     # Assets estáticos
├── next.config.js
└── package.json

Server Components vs Client Components

Server Components (Por Defecto)

Características:

  • Renderizados en el servidor
  • No incluyen JavaScript en el bundle del cliente
  • Pueden acceder directamente a bases de datos y APIs
  • Mejor para SEO y performance
// app/products/page.tsx
// Este es un Server Component por defecto

async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    cache: 'no-store' // Datos siempre frescos
  });

  if (!res.ok) throw new Error('Failed to fetch products');
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <div>
      <h1>Nuestros Productos</h1>
      <div className="grid grid-cols-3 gap-4">
        {products.map(product => (
          <div key={product.id} className="border p-4">
            <h2>{product.name}</h2>
            <p>${product.price}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

Ventaja: Este código corre en el servidor. El cliente recibe HTML renderizado, sin el código del fetch ni datos sensibles.

Client Components

Necesarios cuando usas:

  • Hooks de React (useState, useEffect, useContext, etc.)
  • Event listeners (onClick, onChange, etc.)
  • Browser-only APIs (localStorage, window, etc.)
  • Librerías que dependen del DOM
// app/components/Counter.tsx
'use client' // Directiva obligatoria

import { useState } from 'react'

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

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Incrementar
      </button>
    </div>
  )
}

Composición Híbrida (Patrón Recomendado)

// app/dashboard/page.tsx (Server Component)
import { UserStats } from './UserStats' // Server Component
import { InteractiveChart } from './InteractiveChart' // Client Component
import { db } from '@/lib/db'

export default async function DashboardPage() {
  // Fetch data en el servidor
  const user = await db.user.findUnique({ where: { id: 1 } })
  const stats = await db.stats.findMany({ where: { userId: user.id } })

  return (
    <div>
      <h1>Dashboard de {user.name}</h1>

      {/* Server Component - sin JS en cliente */}
      <UserStats data={stats} />

      {/* Client Component - interactividad necesaria */}
      <InteractiveChart data={stats} />
    </div>
  )
}

// app/dashboard/InteractiveChart.tsx
'use client'

import { useState } from 'react'
import { Chart } from 'recharts'

export function InteractiveChart({ data }) {
  const [chartType, setChartType] = useState('line')

  return (
    <div>
      <select onChange={(e) => setChartType(e.target.value)}>
        <option value="line">Línea</option>
        <option value="bar">Barras</option>
      </select>
      <Chart type={chartType} data={data} />
    </div>
  )
}

Routing en App Router

Rutas Básicas

app/
├── page.tsx              → /
├── about/
│   └── page.tsx          → /about
├── blog/
│   ├── page.tsx          → /blog
│   └── [slug]/
│       └── page.tsx      → /blog/cualquier-slug
└── shop/
    └── [[...slug]]/
        └── page.tsx      → /shop, /shop/categoria, /shop/categoria/subcategoria

Rutas Dinámicas

// app/blog/[slug]/page.tsx

interface PageProps {
  params: {
    slug: string
  }
  searchParams: {
    [key: string]: string | string[] | undefined
  }
}

export default async function BlogPostPage({ params, searchParams }: PageProps) {
  const post = await getPostBySlug(params.slug)

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      {searchParams.preview && <p>Modo preview activado</p>}
    </article>
  )
}

// Generar metadata dinámica
export async function generateMetadata({ params }: PageProps) {
  const post = await getPostBySlug(params.slug)

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage]
    }
  }
}

// Para SSG: generar rutas estáticas en build time
export async function generateStaticParams() {
  const posts = await getAllPosts()

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

Grupos de Rutas

app/
├── (marketing)/          # Grupo sin impacto en URL
│   ├── layout.tsx        # Layout compartido
│   ├── page.tsx          → /
│   ├── about/
│   │   └── page.tsx      → /about
│   └── pricing/
│       └── page.tsx      → /pricing
└── (dashboard)/
    ├── layout.tsx        # Otro layout diferente
    ├── dashboard/
    │   └── page.tsx      → /dashboard
    └── settings/
        └── page.tsx      → /settings

Beneficio: Diferentes layouts sin afectar la estructura de URLs.

// app/(marketing)/layout.tsx
export default function MarketingLayout({ children }) {
  return (
    <div>
      <header>Logo | Nav | CTA</header>
      <main>{children}</main>
      <footer>Marketing Footer</footer>
    </div>
  )
}

// app/(dashboard)/layout.tsx
export default function DashboardLayout({ children }) {
  return (
    <div className="flex">
      <aside>Sidebar Navigation</aside>
      <main className="flex-1">{children}</main>
    </div>
  )
}

Rutas Paralelas

app/
└── dashboard/
    ├── @user/
    │   └── page.tsx      # Slot "user"
    ├── @analytics/
    │   └── page.tsx      # Slot "analytics"
    └── layout.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  user,
  analytics
}: {
  children: React.ReactNode
  user: React.ReactNode
  analytics: React.ReactNode
}) {
  return (
    <div>
      <div className="grid grid-cols-2 gap-4">
        <div>{user}</div>
        <div>{analytics}</div>
      </div>
      <div>{children}</div>
    </div>
  )
}

Data Fetching

Fetch en Server Components

// Cache automático (default: force-cache)
async function getProducts() {
  const res = await fetch('https://api.example.com/products')
  return res.json()
}

// Sin cache (siempre fresh)
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    cache: 'no-store'
  })
  return res.json()
}

// Revalidar cada X segundos (ISR)
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 3600 } // Revalidar cada hora
  })
  return res.json()
}

// Usando directamente en DB (sin API)
import { db } from '@/lib/prisma'

async function getProducts() {
  return await db.product.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' }
  })
}

Parallel Data Fetching

// ❌ Secuencial (lento)
export default async function Page() {
  const user = await getUser()
  const posts = await getPosts()
  const comments = await getComments()

  // Total time = time(user) + time(posts) + time(comments)
}

// ✅ Paralelo (rápido)
export default async function Page() {
  const [user, posts, comments] = await Promise.all([
    getUser(),
    getPosts(),
    getComments()
  ])

  // Total time = max(time(user), time(posts), time(comments))
}

Streaming con Suspense

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { UserProfile } from './UserProfile'
import { RecentActivity } from './RecentActivity'

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>

      {/* Se renderiza inmediatamente sin esperar */}
      <Suspense fallback={<UserProfileSkeleton />}>
        <UserProfile />
      </Suspense>

      {/* Stream independiente */}
      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
    </div>
  )
}

// UserProfile.tsx (Server Component con datos lentos)
async function UserProfile() {
  const user = await fetch('https://slow-api.com/user') // 2 segundos

  return <div>...</div>
}

Resultado: La página se renderiza progresivamente. Los usuarios ven contenido inmediatamente, no esperan a que TODO cargue.

Loading y Error Handling

Loading States

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
      <div className="h-64 bg-gray-200 rounded"></div>
    </div>
  )
}

Se muestra automáticamente mientras page.tsx carga.

Error Boundaries

// app/dashboard/error.tsx
'use client' // Error components deben ser Client Components

import { useEffect } from 'react'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    // Log error a servicio de monitoring
    console.error(error)
  }, [error])

  return (
    <div className="flex flex-col items-center justify-center min-h-screen">
      <h2 className="text-2xl font-bold mb-4">Algo salió mal</h2>
      <p className="text-gray-600 mb-4">{error.message}</p>
      <button
        onClick={reset}
        className="px-4 py-2 bg-blue-500 text-white rounded"
      >
        Intentar nuevamente
      </button>
    </div>
  )
}

Not Found

// app/blog/[slug]/not-found.tsx
export default function NotFound() {
  return (
    <div>
      <h2>Post no encontrado</h2>
      <p>El artículo que buscas no existe.</p>
      <a href="/blog">Ver todos los posts</a>
    </div>
  )
}

// En page.tsx
import { notFound } from 'next/navigation'

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug)

  if (!post) {
    notFound() // Renderiza not-found.tsx
  }

  return <article>...</article>
}

Metadata y SEO

Static Metadata

// app/about/page.tsx
import { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'Acerca de Nosotros',
  description: 'Conoce más sobre nuestra empresa y misión',
  keywords: ['empresa', 'sobre nosotros', 'misión'],
  openGraph: {
    title: 'Acerca de Nosotros',
    description: 'Conoce más sobre nuestra empresa',
    images: ['/og-image.jpg'],
  },
  twitter: {
    card: 'summary_large_image',
    title: 'Acerca de Nosotros',
    description: 'Conoce más sobre nuestra empresa',
    images: ['/twitter-image.jpg'],
  },
}

export default function AboutPage() {
  return <div>...</div>
}

Dynamic Metadata

// app/products/[id]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
  const product = await getProduct(params.id)

  return {
    title: product.name,
    description: product.description,
    openGraph: {
      title: product.name,
      description: product.description,
      images: [
        {
          url: product.image,
          width: 1200,
          height: 630,
          alt: product.name,
        },
      ],
    },
  }
}

JSON-LD para Rich Snippets

export default function ProductPage({ product }) {
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    image: product.image,
    description: product.description,
    sku: product.sku,
    offers: {
      '@type': 'Offer',
      price: product.price,
      priceCurrency: 'USD',
      availability: 'https://schema.org/InStock',
    },
  }

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <div>...</div>
    </>
  )
}

Server Actions

Nueva forma de manejar mutaciones sin API routes:

// app/actions.ts
'use server'

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

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  // Validación
  if (!title || !content) {
    return { error: 'Todos los campos son requeridos' }
  }

  // Crear en DB
  const post = await db.post.create({
    data: { title, content, published: true }
  })

  // Revalidar cache
  revalidatePath('/blog')

  return { success: true, post }
}

// app/blog/new/page.tsx
import { createPost } from '@/app/actions'

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Título" required />
      <textarea name="content" placeholder="Contenido" required />
      <button type="submit">Publicar</button>
    </form>
  )
}

Con Progressive Enhancement

'use client'

import { useFormStatus, useFormState } from 'react-dom'
import { createPost } from '@/app/actions'

function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Publicando...' : 'Publicar'}
    </button>
  )
}

export default function NewPostForm() {
  const [state, formAction] = useFormState(createPost, null)

  return (
    <form action={formAction}>
      <input name="title" placeholder="Título" required />
      <textarea name="content" placeholder="Contenido" required />

      {state?.error && (
        <p className="text-red-500">{state.error}</p>
      )}

      <SubmitButton />
    </form>
  )
}

Optimizaciones

Image Component

import Image from 'next/image'

export default function ProductCard({ product }) {
  return (
    <div>
      <Image
        src={product.image}
        alt={product.name}
        width={400}
        height={300}
        placeholder="blur"
        blurDataURL={product.blurDataUrl}
        priority={false} // true para above-the-fold images
      />
      <h2>{product.name}</h2>
    </div>
  )
}

Beneficios automáticos:

  • Lazy loading
  • Responsive images
  • Formato moderno (WebP/AVIF)
  • Previene layout shift

Font Optimization

// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
})

const robotoMono = Roboto_Mono({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-roboto-mono',
})

export default function RootLayout({ children }) {
  return (
    <html lang="es" className={`${inter.variable} ${robotoMono.variable}`}>
      <body className="font-sans">{children}</body>
    </html>
  )
}

Route Segment Config

// app/blog/page.tsx

// Configurar comportamiento del segmento
export const dynamic = 'auto' // 'auto' | 'force-dynamic' | 'error' | 'force-static'
export const dynamicParams = true // true | false
export const revalidate = 3600 // false | 0 | number
export const fetchCache = 'auto' // 'auto' | 'default-cache' | 'only-cache' | 'force-cache' | 'force-no-store' | 'default-no-store' | 'only-no-store'
export const runtime = 'nodejs' // 'nodejs' | 'edge'
export const preferredRegion = 'auto' // 'auto' | 'global' | 'home' | ['iad1', 'sfo1']

export default function BlogPage() {
  return <div>...</div>
}

Deployment en Vercel

# Instalar Vercel CLI
npm i -g vercel

# Deploy
vercel

# Deploy a producción
vercel --prod

Optimizaciones automáticas de Vercel:

  • Edge Functions
  • Image Optimization CDN
  • Analytics integrado
  • Automatic HTTPS
  • Preview deployments por branch

Mejores Prácticas

1. Coloca Client Components en la Hoja

// ❌ Mal - Todo se vuelve Client Component
'use client'

export default function Page() {
  return (
    <div>
      <ServerData />  {/* Ya no es Server Component */}
      <Counter />     {/* Necesita ser Client */}
    </div>
  )
}

// ✅ Bien - Solo Counter es Client Component
export default function Page() {
  return (
    <div>
      <ServerData />  {/* Sigue siendo Server Component */}
      <Counter />     {/* Client Component */}
    </div>
  )
}

2. Usa Loading y Error Boundaries

app/
└── dashboard/
    ├── page.tsx
    ├── loading.tsx    ← Siempre incluir
    └── error.tsx      ← Siempre incluir

3. Aprovecha el Caching

// Para datos que cambian poco
fetch(url, { next: { revalidate: 3600 } })

// Para datos en tiempo real
fetch(url, { cache: 'no-store' })

// Para revalidar bajo demanda
import { revalidatePath, revalidateTag } from 'next/cache'

Próximos Pasos

Esta Semana

  1. Migra un proyecto simple de CRA a Next.js
  2. Experimenta con Server Components vs Client Components
  3. Implementa metadata dinámica en rutas existentes
  4. Prueba Server Actions para formularios

Próximos 30 Días

  1. Construye un blog completo con markdown
  2. Implementa autenticación con NextAuth.js
  3. Integra Prisma para base de datos
  4. Optimiza performance con Analytics

Recursos Adicionales

  • Docs oficiales: nextjs.org/docs
  • Curso: "Next.js App Router" en Frontend Masters
  • Ejemplos: github.com/vercel/next.js/tree/canary/examples

Continúa Aprendiendo

  • Backend: "Arquitectura de Microservicios con Node.js" para APIs robustas
  • Complementa con: Aprende Prisma, tRPC, y Tailwind CSS

Recuerda: Next.js 14 con App Router es un cambio de paradigma. Piensa en Server-first, usa Client Components solo cuando sea necesario.

¿Te gustó este artículo?

Explora más contenido educativo en nuestras categorías

Explorar más contenido