BlogBase de datos

PostgreSQL + Prisma: setup para Next.js que aguanta producción real

Todo lo que necesitas saber para conectar Postgres a tu app Next.js sin que explote en producción: schema real, singleton pattern, connection pooling, migraciones y los gotchas que nadie documenta.

Por Edward Díaz — Edwsystem30 de abril de 202510 min de lectura

Por qué elegimos PostgreSQL + Prisma en todos nuestros proyectos

Cuando arrancamos un proyecto nuevo en Edwsystem, la pregunta de base de datos dura aproximadamente dos segundos: PostgreSQL + Prisma. No MongoDB, no SQLite, no MySQL. Postgres lleva décadas siendo la base de datos relacional más robusta del ecosistema open source, y Prisma nos da la capa de tipado que necesitamos para trabajar rápido sin romper producción.

Esto no es dogma. Lo hemos probado en sistemas de leads, bots de WhatsApp, plataformas SaaS y dashboards internos. El patrón funciona porque resuelve los problemas reales que aparecen cuando el código deja de ser local y empieza a correr en servidores con límites de conexión, hot reload, y funciones serverless.

Por qué PostgreSQL (y no otra cosa)

Postgres no es solo "una base de datos SQL". Es un sistema completo que incluye soporte nativo para JSON/JSONB (con indexado), full-text search sin plugins externos, transacciones ACID reales, tipos de datos avanzados (arrays, enums, UUID), y extensiones como pg_vector para embeddings de IA.

En la práctica, esto significa que puedes guardar datos estructurados y semi-estructurados en la misma base, hacer búsquedas de texto eficientes sin Elasticsearch, y tener la consistencia de ACID cuando manejas dinero, leads o estados críticos.

Y lo mejor para proyectos en etapa inicial: es gratis en Railway, Supabase y Neon. No hay excusa para usar SQLite en producción cuando tienes Postgres hosted sin costo.

Por qué Prisma (y sus gotchas honestos)

Prisma es un ORM moderno que genera tipos TypeScript directamente del schema. Eso significa que si tu tabla User tiene un campo email, tu editor sabe que existe, qué tipo tiene, y te avisa en tiempo de compilación si lo escribes mal. Eso solo ya justifica el setup.

Dicho eso, Prisma tiene limitaciones reales que debes conocer: no funciona en el Edge Runtime de Next.js con el cliente estándar, puede generar N+1 queries si no prestas atención, y el cliente crea conexiones que pueden agotar el pool en entornos serverless. Todo eso lo veremos con soluciones concretas.

Instalación inicial

Desde la raíz de tu proyecto Next.js:

npm install prisma @prisma/client
npx prisma init

Esto crea el directorio prisma/ con un schema.prisma base, y agrega DATABASE_URL a tu .env. El formato del string de conexión es:

# .env
DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/DBNAME?schema=public"

# Ejemplo Railway
DATABASE_URL="postgresql://postgres:tu-pass@roundhouse.proxy.rlwy.net:49823/railway"

# Ejemplo Neon (serverless - incluye parámetro SSL)
DATABASE_URL="postgresql://user:pass@ep-silent-fog-123.us-east-2.aws.neon.tech/neondb?sslmode=require"

Schema real: usuarios, leads y sesiones

Un schema de producción típico en nuestros proyectos. Nota los índices explícitos, las relaciones y el uso de enums:

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum LeadStatus {
  NEW
  CONTACTED
  QUALIFIED
  CONVERTED
  LOST
}

enum Role {
  ADMIN
  AGENT
  VIEWER
}

model User {
  id        String    @id @default(cuid())
  email     String    @unique
  name      String?
  role      Role      @default(VIEWER)
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt

  leads     Lead[]
  sessions  Session[]

  @@index([email])
}

model Lead {
  id        String     @id @default(cuid())
  name      String
  email     String?
  phone     String?
  source    String?
  status    LeadStatus @default(NEW)
  score     Int        @default(0)
  notes     String?
  metadata  Json?
  createdAt DateTime   @default(now())
  updatedAt DateTime   @updatedAt

  assignedTo   User?   @relation(fields: [assignedToId], references: [id])
  assignedToId String?

  @@index([status])
  @@index([assignedToId])
  @@index([createdAt])
}

model Session {
  id        String   @id @default(cuid())
  userId    String
  token     String   @unique
  expiresAt DateTime
  createdAt DateTime @default(now())

  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([token])
  @@index([userId])
}

Nota sobre índices: Prisma no crea índices automáticos para las foreign keys en PostgreSQL (a diferencia de MySQL). Siempre agrega @@index en los campos que usas como filtro frecuente o en relaciones. Un query sin índice en una tabla con 100k registros puede pasar de 2ms a 800ms.

El singleton de Prisma: crítico en Next.js

Este es el error más común con Prisma en Next.js: crear un new PrismaClient() directamente en cada archivo. En desarrollo, el hot reload ejecuta ese código cada vez, y terminas con docenas de conexiones abiertas que saturan tu base de datos.

La solución es el patrón singleton que guarda la instancia en el objeto global de Node.js (que persiste entre hot reloads):

// lib/prisma.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development'
      ? ['query', 'error', 'warn']
      : ['error'],
  })

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma
}

Úsalo así en cualquier Server Action, Route Handler o Server Component:

// app/api/leads/route.ts
import { prisma } from '@/lib/prisma'
import { NextResponse } from 'next/server'

export async function GET() {
  const leads = await prisma.lead.findMany({
    where: { status: 'NEW' },
    select: {
      id: true,
      name: true,
      email: true,
      score: true,
      createdAt: true,
    },
    orderBy: { createdAt: 'desc' },
    take: 50,
  })
  return NextResponse.json(leads)
}

Connection pooling: el problema que destruye apps en producción

PostgreSQL tiene un límite de conexiones simultáneas (típicamente 100 en instancias pequeñas). En entornos serverless —como Vercel o funciones de Railway— cada invocación puede crear una conexión nueva, y con tráfico moderado te quedas sin conexiones disponibles y empiezas a ver errores como too many connections.

La solución es usar un connection pooler. Las opciones más comunes:

  • Supabase: incluye PgBouncer automáticamente. Agrega ?pgbouncer=true&connection_limit=1 al DATABASE_URL.
  • Neon: usa connection pooling nativo por diseño. Usa el endpoint de pooling que te dan en el dashboard.
  • Railway: no incluye pooler. Agrega PgBouncer como servicio adicional, o usa Prisma Accelerate.
# .env — Supabase con connection pooling activado
DATABASE_URL="postgresql://postgres.xxx:password@aws-0-us-east-1.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1"

# URL directa para migraciones (sin pooler — pgbouncer no soporta DDL)
DIRECT_URL="postgresql://postgres.xxx:password@aws-0-us-east-1.pooler.supabase.com:5432/postgres"
// prisma/schema.prisma — cuando usas URL directa para migraciones
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

Migraciones: dev vs producción

Prisma tiene dos comandos de migración y es crítico entender cuándo usar cada uno:

# Desarrollo: crea una nueva migración Y la aplica en tu DB local
# También regenera el Prisma Client
npx prisma migrate dev --name add-lead-score

# Producción: aplica las migraciones PENDIENTES sin crear nuevas
# Solo ejecuta los archivos SQL que ya existen en prisma/migrations/
npx prisma migrate deploy

# Ver estado de las migraciones
npx prisma migrate status

# Regenerar el cliente después de cambios en schema (sin migrar)
npx prisma generate

Regla de oro: nunca corras prisma migrate dev en producción. Ese comando puede hacer reset de la base de datos si detecta drift. En tu pipeline de CI/CD o en el startup de tu servidor, siempre usa migrate deploy.

Prisma y Edge Runtime: la incompatibilidad que te va a picar

El cliente estándar de Prisma usa módulos de Node.js que no están disponibles en el Edge Runtime de Next.js (el runtime que usan los Middleware y ciertos Route Handlers con export const runtime = 'edge'). Si intentas usarlo, obtendrás errores de build.

Tienes dos opciones:

  • La más simple: no uses el Edge Runtime para rutas que acceden a la base de datos. Quita export const runtime = 'edge' de esas rutas y corre en Node.js estándar.
  • Si necesitas Edge: usa @prisma/adapter-neon con el driver HTTP de Neon, que sí es compatible con Edge.
// Opción Edge con Neon driver
import { neon } from '@neondatabase/serverless'
import { PrismaNeon } from '@prisma/adapter-neon'
import { PrismaClient } from '@prisma/client'

const sql = neon(process.env.DATABASE_URL!)
const adapter = new PrismaNeon(sql)
export const prisma = new PrismaClient({ adapter })

Tips de performance que usamos en producción

1. Usa select siempre que puedas

Por defecto, Prisma trae todos los campos del modelo. Si solo necesitas nombre y email, pedir todos los campos es un desperdicio de red y memoria:

// ❌ Trae todos los campos incluyendo metadata, notes, etc.
const leads = await prisma.lead.findMany()

// ✅ Solo trae lo que necesitas
const leads = await prisma.lead.findMany({
  select: { id: true, name: true, email: true, status: true },
})

2. Evita N+1 con include

Si necesitas datos de relaciones, cárgalos en una sola query con include. No hagas un loop con queries individuales.

// ❌ N+1: una query por cada lead para obtener el usuario asignado
const leads = await prisma.lead.findMany()
for (const lead of leads) {
  lead.user = await prisma.user.findUnique({ where: { id: lead.assignedToId } })
}

// ✅ Una sola query con JOIN
const leads = await prisma.lead.findMany({
  include: {
    assignedTo: {
      select: { id: true, name: true, email: true },
    },
  },
})

3. SQL crudo con $queryRaw cuando necesitas más control

// Para queries complejas que Prisma no puede expresar fácilmente
const stats = await prisma.$queryRaw<
  Array<{ status: string; count: bigint }>
>`
  SELECT status, COUNT(*) as count
  FROM "Lead"
  WHERE "createdAt" > NOW() - INTERVAL '30 days'
  GROUP BY status
  ORDER BY count DESC
`

Estrategia de backups

Los backups no son opcionales en producción. Nuestra estrategia según la plataforma:

  • Supabase: backups automáticos diarios incluidos en todos los planes. El plan Pro agrega point-in-time recovery. Suficiente para la mayoría de proyectos.
  • Railway: no incluye backups automáticos en el plan base. Configura un cron job que corra pg_dump y suba el resultado a R2 o S3.
  • Neon: incluye historial de branches. Puedes hacer restore a cualquier punto de los últimos 7 días en el plan gratuito.
# Backup manual con pg_dump (Railway u otro Postgres)
pg_dump "$DATABASE_URL"   --format=custom   --no-acl   --no-owner   -f "backup-$(date +%Y%m%d-%H%M%S).dump"

# Restaurar
pg_restore --verbose --clean --no-acl --no-owner   -d "$DATABASE_URL" backup-20250430-120000.dump

Nuestra recomendación de hosting según el proyecto

Railway$5/mes

Ideal para proyectos pequeños y MVPs. Setup en 5 minutos, variables de entorno automáticas, PostgreSQL incluido. La simplicidad lo vale. Sin backups automáticos en el plan base.

SupabaseFree tier generoso

500MB gratis, backups incluidos, PgBouncer nativo, storage y auth de regalo. Pausa el proyecto tras 7 días de inactividad en el plan gratuito — considera eso para proyectos en producción activa.

Para proyectos que necesitan escalar, Neon es nuestra tercera opción: autoscaling real, branching para tests, y un free tier honesto. Pero Railway y Supabase cubren el 90% de los casos que manejamos.

Resumen ejecutivo

Si tienes que quedarte con algo de este artículo, que sea esto: usa el singleton de Prisma, activa connection pooling antes de hacer deploy, y usa migrate deploy —nunca migrate dev— en producción. El resto son optimizaciones. PostgreSQL + Prisma es una combinación sólida que escala bien y no te da sorpresas una vez que entiendes sus límites.

Si estás construyendo un sistema de leads, un SaaS o cualquier app con datos relacionales, este es el stack que usamos. Si necesitas ayuda para implementarlo o hay algo que no quedó claro, escríbenos.

¿Te fue útil este artículo?

Déjame tu email y te aviso cuando publique nuevos artículos técnicos.