API Routes y Manejo de Datos en Next.js 14 App Router: PostgreSQL, Prisma y Server Components

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' })
}
  Importante

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 }
  )
}
}
  Importante

Usa variables de entorno para credenciales de base de datos. Nunca hardcodees passwords en el código fuente.

  Performance

Los Server Components eliminan waterfalls de requests. Usa Promise.all() para fetch paralelo y Suspense para streaming progresivo.

  Consejo

Combina cache strategies: force-cache para contenido estático, revalidate para datos que cambian ocasionalmente, y no-store para datos en tiempo real.