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
- Migra un proyecto simple de CRA a Next.js
- Experimenta con Server Components vs Client Components
- Implementa metadata dinámica en rutas existentes
- Prueba Server Actions para formularios
Próximos 30 Días
- Construye un blog completo con markdown
- Implementa autenticación con NextAuth.js
- Integra Prisma para base de datos
- 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.