Next.js 13+ simplifica el manejo de datos con Server Components y API Routes. Aprende a crear APIs REST, hacer fetch de datos y conectar con PostgreSQL de forma eficiente.
Las API Routes en Next.js se crean en la carpeta app/api/ y exportan funciones HTTP.
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
// GET /api/users
export async function GET() {
const users = [
{ id: 1, name: 'Juan Pérez', email: 'juan@email.com' },
{ id: 2, name: 'María García', email: 'maria@email.com' },
{ id: 3, name: 'Carlos López', email: 'carlos@email.com' }
]
return NextResponse.json(users)
}
// POST /api/users
export async function POST(request: NextRequest) {
const body = await request.json()
const newUser = {
id: Date.now(),
name: body.name,
email: body.email,
createdAt: new Date().toISOString()
}
return NextResponse.json(newUser, { status: 201 })
} // app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
let products = [
{ id: 1, name: 'Laptop', price: 999.99, stock: 10 },
{ id: 2, name: 'Mouse', price: 29.99, stock: 50 },
]
export async function GET() {
return NextResponse.json(products)
}
export async function POST(request: NextRequest) {
const { name, price, stock } = await request.json()
const newProduct = {
id: Date.now(),
name,
price: parseFloat(price),
stock: parseInt(stock)
}
products.push(newProduct)
return NextResponse.json(newProduct, { status: 201 })
}
// app/api/products/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const product = products.find(p => p.id === parseInt(params.id))
if (!product) {
return NextResponse.json(
{ error: 'Producto no encontrado' },
{ status: 404 }
)
}
return NextResponse.json(product)
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const { name, price, stock } = await request.json()
const productIndex = products.findIndex(p => p.id === parseInt(params.id))
if (productIndex === -1) {
return NextResponse.json(
{ error: 'Producto no encontrado' },
{ status: 404 }
)
}
products[productIndex] = {
...products[productIndex],
name,
price: parseFloat(price),
stock: parseInt(stock)
}
return NextResponse.json(products[productIndex])
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const productIndex = products.findIndex(p => p.id === parseInt(params.id))
if (productIndex === -1) {
return NextResponse.json(
{ error: 'Producto no encontrado' },
{ status: 404 }
)
}
products.splice(productIndex, 1)
return NextResponse.json({ message: 'Producto eliminado' })
} Las API Routes en Next.js 13+ usan Web APIs estándar (Request/Response). Cada método HTTP es una función exportada separada.
Estructura tus endpoints de forma consistente para facilitar el consumo desde el frontend.
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json()
// Validación básica
if (!email || !password) {
return NextResponse.json(
{ error: 'Email y contraseña son requeridos' },
{ status: 400 }
)
}
// Simulación de autenticación
if (email === 'admin@test.com' && password === '123456') {
const user = {
id: 1,
email,
name: 'Administrador',
token: 'jwt-token-example'
}
return NextResponse.json({ user, success: true })
}
return NextResponse.json(
{ error: 'Credenciales inválidas' },
{ status: 401 }
)
} catch (error) {
return NextResponse.json(
{ error: 'Error interno del servidor' },
{ status: 500 }
)
}
}
// app/api/posts/route.ts
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '10')
const search = searchParams.get('search') || ''
// Simulación de datos paginados
const allPosts = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
title: `Post ${i + 1}`,
content: `Contenido del post ${i + 1}`,
author: `Autor ${(i % 5) + 1}`
}))
let filteredPosts = allPosts
if (search) {
filteredPosts = allPosts.filter(post =>
post.title.toLowerCase().includes(search.toLowerCase())
)
}
const startIndex = (page - 1) * limit
const endIndex = startIndex + limit
const posts = filteredPosts.slice(startIndex, endIndex)
return NextResponse.json({
posts,
pagination: {
page,
limit,
total: filteredPosts.length,
totalPages: Math.ceil(filteredPosts.length / limit)
}
})
} // middleware.ts
import { NextRequest, NextResponse } from 'next/server'
export function middleware(request: NextRequest) {
// Solo aplicar a rutas de API
if (request.nextUrl.pathname.startsWith('/api/')) {
// CORS headers
const response = NextResponse.next()
response.headers.set('Access-Control-Allow-Origin', '*')
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization')
// Rate limiting básico (en producción usar Redis)
const ip = request.ip || 'unknown'
console.log(`API Request from: ${ip}`)
return response
}
}
export const config = {
matcher: '/api/:path*'
} Los Server Components pueden hacer fetch directamente sin useEffect ni estados de carga.
// app/users/page.tsx
async function getUsers() {
const res = await fetch('https://jsonplaceholder.typicode.com/users', {
// Revalidar cada 60 segundos
next: { revalidate: 60 }
})
if (!res.ok) {
throw new Error('Error al cargar usuarios')
}
return res.json()
}
export default async function UsersPage() {
const users = await getUsers()
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Lista de Usuarios</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{users.map((user: any) => (
<div key={user.id} className="bg-white p-6 rounded-lg shadow">
<h3 className="font-semibold text-lg">{user.name}</h3>
<p className="text-gray-600">{user.email}</p>
<p className="text-sm text-gray-500">{user.company.name}</p>
</div>
))}
</div>
</div>
)
}
// app/posts/[id]/page.tsx
async function getPost(id: string) {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
// Cache estático - no revalidar
cache: 'force-cache'
})
if (!res.ok) {
throw new Error('Post no encontrado')
}
return res.json()
}
export default async function PostPage({
params
}: {
params: { id: string }
}) {
const post = await getPost(params.id)
return (
<article className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-4">{post.title}</h1>
<div className="prose max-w-none">
<p>{post.body}</p>
</div>
</article>
)
} // lib/api.ts
export async function fetchWithError<T>(
url: string,
options?: RequestInit
): Promise<T> {
try {
const res = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
...options,
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
return await res.json()
} catch (error) {
console.error('Fetch error:', error)
throw error
}
}
// app/dashboard/page.tsx
import { fetchWithError } from '@/lib/api'
async function getDashboardData() {
try {
const [users, posts, stats] = await Promise.all([
fetchWithError('/api/users'),
fetchWithError('/api/posts?limit=5'),
fetchWithError('/api/stats')
])
return { users, posts, stats }
} catch (error) {
console.error('Error loading dashboard:', error)
return { users: [], posts: [], stats: null }
}
}
export default async function DashboardPage() {
const { users, posts, stats } = await getDashboardData()
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Dashboard</h1>
{stats && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold">Usuarios</h3>
<p className="text-3xl font-bold text-blue-600">{stats.users}</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold">Posts</h3>
<p className="text-3xl font-bold text-green-600">{stats.posts}</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold">Visitas</h3>
<p className="text-3xl font-bold text-purple-600">{stats.visits}</p>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Usuarios Recientes</h2>
{users.length > 0 ? (
<div className="space-y-2">
{users.slice(0, 5).map((user: any) => (
<div key={user.id} className="flex justify-between">
<span>{user.name}</span>
<span className="text-gray-500">{user.email}</span>
</div>
))}
</div>
) : (
<p className="text-gray-500">No hay usuarios disponibles</p>
)}
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Posts Recientes</h2>
{posts.length > 0 ? (
<div className="space-y-2">
{posts.map((post: any) => (
<div key={post.id}>
<h4 className="font-medium">{post.title}</h4>
<p className="text-sm text-gray-600 truncate">{post.content}</p>
</div>
))}
</div>
) : (
<p className="text-gray-500">No hay posts disponibles</p>
)}
</div>
</div>
</div>
)
} Los Server Components hacen fetch en el servidor, reduciendo el JavaScript del cliente y mejorando el SEO. Usa cache y revalidate para optimizar las requests.
Next.js ofrece diferentes estrategias para manejar datos según tus necesidades.
// app/blog/page.tsx - Generado en build time
async function getBlogPosts() {
const res = await fetch('https://api.blog.com/posts', {
// Cache permanente - ideal para contenido que no cambia
cache: 'force-cache'
})
return res.json()
}
export default async function BlogPage() {
const posts = await getBlogPosts()
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{posts.map((post: any) => (
<article key={post.id} className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-2">{post.title}</h2>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<time className="text-sm text-gray-500">
{new Date(post.date).toLocaleDateString()}
</time>
</article>
))}
</div>
)
} // app/news/page.tsx - Generado en cada request
async function getLatestNews() {
const res = await fetch('https://api.news.com/latest', {
// Sin cache - siempre fresh
cache: 'no-store'
})
return res.json()
}
export default async function NewsPage() {
const news = await getLatestNews()
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">Últimas Noticias</h1>
{news.map((article: any) => (
<div key={article.id} className="border-b pb-4">
<h3 className="font-semibold">{article.headline}</h3>
<p className="text-gray-600">{article.summary}</p>
<span className="text-xs text-gray-500">
Hace {article.timeAgo}
</span>
</div>
))}
</div>
)
} // app/products/page.tsx - Revalida cada 30 segundos
async function getProducts() {
const res = await fetch('https://api.store.com/products', {
// Revalidar cada 30 segundos
next: { revalidate: 30 }
})
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts()
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{products.map((product: any) => (
<div key={product.id} className="bg-white p-4 rounded-lg shadow">
<img
src={product.image}
alt={product.name}
className="w-full h-48 object-cover rounded mb-4"
/>
<h3 className="font-semibold">{product.name}</h3>
<p className="text-lg font-bold text-green-600">
${product.price}
</p>
<p className="text-sm text-gray-500">
Stock: {product.stock}
</p>
</div>
))}
</div>
)
} Combina diferentes fuentes de datos para crear dashboards completos.
// app/admin/dashboard/page.tsx
import { Suspense } from 'react'
async function getOverviewStats() {
const res = await fetch('http://localhost:3000/api/stats/overview', {
next: { revalidate: 300 } // 5 minutos
})
return res.json()
}
async function getRecentActivity() {
const res = await fetch('http://localhost:3000/api/activity/recent', {
next: { revalidate: 60 } // 1 minuto
})
return res.json()
}
async function getSalesChart() {
const res = await fetch('http://localhost:3000/api/sales/chart', {
next: { revalidate: 900 } // 15 minutos
})
return res.json()
}
export default async function AdminDashboard() {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Dashboard Administrativo</h1>
{/* Stats Overview */}
<Suspense fallback={<StatsLoading />}>
<StatsOverview />
</Suspense>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Activity */}
<Suspense fallback={<ActivityLoading />}>
<RecentActivity />
</Suspense>
{/* Sales Chart */}
<Suspense fallback={<ChartLoading />}>
<SalesChart />
</Suspense>
</div>
</div>
)
}
async function StatsOverview() {
const stats = await getOverviewStats()
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
title="Ventas Totales"
value={`$${stats.totalSales.toLocaleString()}`}
change={stats.salesChange}
color="green"
/>
<StatCard
title="Nuevos Usuarios"
value={stats.newUsers.toLocaleString()}
change={stats.usersChange}
color="blue"
/>
<StatCard
title="Pedidos"
value={stats.orders.toLocaleString()}
change={stats.ordersChange}
color="purple"
/>
<StatCard
title="Conversión"
value={`${stats.conversion}%`}
change={stats.conversionChange}
color="orange"
/>
</div>
)
}
function StatCard({ title, value, change, color }: {
title: string
value: string
change: number
color: string
}) {
const colorClasses = {
green: 'text-green-600 bg-green-50',
blue: 'text-blue-600 bg-blue-50',
purple: 'text-purple-600 bg-purple-50',
orange: 'text-orange-600 bg-orange-50'
}
return (
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-sm font-medium text-gray-500">{title}</h3>
<p className={`text-2xl font-bold ${colorClasses[color as keyof typeof colorClasses].split(' ')[0]}`}>
{value}
</p>
<p className={`text-sm ${change >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{change >= 0 ? '+' : ''}{change}% vs mes anterior
</p>
</div>
)
}
async function RecentActivity() {
const activities = await getRecentActivity()
return (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Actividad Reciente</h2>
<div className="space-y-3">
{activities.map((activity: any) => (
<div key={activity.id} className="flex items-center space-x-3">
<div className={`w-2 h-2 rounded-full ${
activity.type === 'sale' ? 'bg-green-500' :
activity.type === 'user' ? 'bg-blue-500' : 'bg-gray-500'
}`}></div>
<div className="flex-1">
<p className="text-sm">{activity.description}</p>
<p className="text-xs text-gray-500">{activity.timeAgo}</p>
</div>
</div>
))}
</div>
</div>
)
}
async function SalesChart() {
const chartData = await getSalesChart()
return (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Ventas por Mes</h2>
<div className="h-64 flex items-end space-x-2">
{chartData.months.map((month: any, index: number) => (
<div key={index} className="flex-1 flex flex-col items-center">
<div
className="bg-blue-500 w-full rounded-t"
style={{
height: `${(month.sales / Math.max(...chartData.months.map((m: any) => m.sales))) * 200}px`
}}
></div>
<span className="text-xs mt-2">{month.name}</span>
</div>
))}
</div>
</div>
)
}
// Loading components
function StatsLoading() {
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-white p-6 rounded-lg shadow animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
<div className="h-8 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-3 bg-gray-200 rounded w-1/3"></div>
</div>
))}
</div>
)
}
function ActivityLoading() {
return (
<div className="bg-white p-6 rounded-lg shadow animate-pulse">
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div>
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center space-x-3 mb-3">
<div className="w-2 h-2 bg-gray-200 rounded-full"></div>
<div className="flex-1 space-y-1">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-3 bg-gray-200 rounded w-1/4"></div>
</div>
</div>
))}
</div>
)
}
function ChartLoading() {
return (
<div className="bg-white p-6 rounded-lg shadow animate-pulse">
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div>
<div className="h-64 bg-gray-200 rounded"></div>
</div>
)
} Conecta tu aplicación Next.js con PostgreSQL para persistencia de datos real.
// lib/db.ts
import { Pool } from 'pg'
const pool = new Pool({
user: process.env.DB_USER,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: parseInt(process.env.DB_PORT || '5432'),
})
export async function query(text: string, params?: any[]) {
const client = await pool.connect()
try {
const result = await client.query(text, params)
return result
} finally {
client.release()
}
}
// .env.local
DB_USER=postgres
DB_HOST=localhost
DB_NAME=nextjs_app
DB_PASSWORD=password
DB_PORT=5432 // app/api/users/route.ts
import { query } from '@/lib/db'
import { NextRequest, NextResponse } from 'next/server'
export async function GET() {
try {
const result = await query('SELECT id, name, email, created_at FROM users ORDER BY created_at DESC')
return NextResponse.json(result.rows)
} catch (error) {
console.error('Database error:', error)
return NextResponse.json(
{ error: 'Error al obtener usuarios' },
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
try {
const { name, email } = await request.json()
const result = await query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
[name, email]
)
return NextResponse.json(result.rows[0], { status: 201 })
} catch (error) {
console.error('Database error:', error)
return NextResponse.json(
{ error: 'Error al crear usuario' },
{ status: 500 }
)
}
}
// app/api/posts/route.ts
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '10')
const offset = (page - 1) * limit
const result = await query(
`SELECT p.*, u.name as author_name
FROM posts p
JOIN users u ON p.user_id = u.id
ORDER BY p.created_at DESC
LIMIT $1 OFFSET $2`,
[limit, offset]
)
const countResult = await query('SELECT COUNT(*) FROM posts')
const total = parseInt(countResult.rows[0].count)
return NextResponse.json({
posts: result.rows,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
})
} catch (error) {
console.error('Database error:', error)
return NextResponse.json(
{ error: 'Error al obtener posts' },
{ status: 500 }
)
}
} Usa variables de entorno para credenciales de base de datos. Nunca hardcodees passwords en el código fuente.
Los Server Components eliminan waterfalls de requests. Usa Promise.all() para fetch paralelo y Suspense para streaming progresivo.
Combina cache strategies: force-cache para contenido estático, revalidate para datos que cambian ocasionalmente, y no-store para datos en tiempo real.