API y Datos en Next.js

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.

API REST Básica

Las API Routes en Next.js se crean en la carpeta app/api/ y exportan funciones HTTP.

Endpoint Básico

typescript
// 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 })
}

CRUD Completo

typescript
// 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.

Preparando Endpoints para Fetch

Estructura tus endpoints de forma consistente para facilitar el consumo desde el frontend.

Endpoints con Validación

typescript
// 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 para APIs

typescript
// 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*'
}

Fetch desde Server Components

Los Server Components pueden hacer fetch directamente sin useEffect ni estados de carga.

Fetch Básico en Server Component

typescript
// 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>
)
}

Fetch con Manejo de Errores

typescript
// 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.

Patrones de Fetching de Datos

Next.js ofrece diferentes estrategias para manejar datos según tus necesidades.

Datos Estáticos (SSG)

typescript
// 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>
)
}

Datos Dinámicos (SSR)

typescript
// 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>
)
}

Revalidación Incremental (ISR)

typescript
// 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>
)
}

Renderizar Datos en Dashboard

Combina diferentes fuentes de datos para crear dashboards completos.

Dashboard con Múltiples Fuentes

typescript
// 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>
)
}

Integración con PostgreSQL

Conecta tu aplicación Next.js con PostgreSQL para persistencia de datos real.

Configuración de Base de Datos

typescript
// 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

API con PostgreSQL

typescript
// 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.