Tema
Base de datos con Prisma
Hasta ahora usaste datos en memoria. En esta lección conectarás tu app de Next.js a una base de datos real con Prisma: definirás modelos, crearás migraciones, leerás datos directamente desde Server Components y harás un CRUD completo con Server Actions. Si ya viste Bases de datos SQL en Node, aquí verás cómo encaja con Next.
Concepto
Con el App Router, el flujo de datos es muy directo:
txt
Server Component ──► prisma.producto.findMany() ──► Base de datos
▲ │
└──────────────── datos ◄────────────────────────┘
Formulario ──► Server Action ──► prisma.producto.create() ──► revalidatePath()Sin API intermedia: los Server Components y las Server Actions corren en el servidor y pueden hablar con la base de datos directamente, de forma segura.
Prisma te da:
- Un archivo
schema.prismadonde declaras tus modelos. - Migraciones que crean y modifican las tablas.
- Un cliente con autocompletado y tipos de TypeScript generados a partir de tu schema.
Instalación
Necesitas una base de datos PostgreSQL. Opciones:
- Local con Docker:
docker run -d --name pg -e POSTGRES_PASSWORD=secreto -p 5432:5432 postgres:17 - En la nube gratis: Neon, Supabase o Prisma Postgres. Copia la cadena de conexión.
bash
npm i -D prisma@6
npm i @prisma/client@6
npx prisma init --datasource-provider postgresqlenv
# .env (Prisma lee este archivo; Next.js también)
DATABASE_URL="postgresql://postgres:secreto@localhost:5432/mi_tienda"Prisma 6 y 7
Igual que en el curso de Node, fijamos Prisma 6 por su configuración sencilla. En Prisma 7 la URL va en prisma.config.ts, el generador se llama prisma-client con output obligatorio y se usa un adaptador (@prisma/adapter-pg). Los modelos y las consultas son iguales.
Paso 1: el schema
txt
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Categoria {
id Int @id @default(autoincrement())
nombre String @unique
slug String @unique
productos Producto[]
}
model Producto {
id Int @id @default(autoincrement())
nombre String
slug String @unique
descripcion String?
precio Decimal @db.Decimal(10, 2)
stock Int @default(0)
imagen String?
publicado Boolean @default(true)
categoriaId Int
categoria Categoria @relation(fields: [categoriaId], references: [id])
creadoEn DateTime @default(now())
actualizado DateTime @updatedAt
@@index([categoriaId])
}bash
npx prisma migrate dev --name init # crea las tablas y genera el cliente
npx prisma studio # interfaz visual para ver/editar datosPaso 2: el cliente (singleton)
En desarrollo, Next.js recarga los módulos con cada cambio. Si creas new PrismaClient() en cada recarga, agotarás las conexiones. La solución es guardar la instancia en globalThis:
ts
// lib/prisma.ts
import 'server-only'
import { PrismaClient } from '@prisma/client'
const globalParaPrisma = globalThis as unknown as { prisma?: PrismaClient }
export const prisma =
globalParaPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error']
})
if (process.env.NODE_ENV !== 'production') globalParaPrisma.prisma = prismaPaso 3: datos de prueba (seed)
ts
// prisma/seed.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const cafe = await prisma.categoria.upsert({
where: { slug: 'cafe' },
update: {},
create: { nombre: 'Café', slug: 'cafe' }
})
const accesorios = await prisma.categoria.upsert({
where: { slug: 'accesorios' },
update: {},
create: { nombre: 'Accesorios', slug: 'accesorios' }
})
await prisma.producto.createMany({
data: [
{ nombre: 'Café de Chiapas 500 g', slug: 'cafe-chiapas', precio: 249, stock: 30, categoriaId: cafe.id },
{ nombre: 'Café de Veracruz 500 g', slug: 'cafe-veracruz', precio: 229, stock: 18, categoriaId: cafe.id },
{ nombre: 'Prensa francesa', slug: 'prensa-francesa', precio: 549, stock: 7, categoriaId: accesorios.id }
],
skipDuplicates: true
})
}
main()
.then(() => console.log('✅ Seed completado'))
.finally(() => prisma.$disconnect())json
// package.json
{
"prisma": { "seed": "node --experimental-strip-types prisma/seed.ts" }
}bash
npx prisma db seed(Si tu versión de Node no ejecuta TypeScript directamente, instala tsx y usa "seed": "tsx prisma/seed.ts".)
Paso 4: leer datos en Server Components
Crea una capa de acceso a datos para reutilizar las consultas:
ts
// lib/productos.ts
import 'server-only'
import { cache } from 'react'
import { prisma } from '@/lib/prisma'
export async function listarProductos({ q, categoria }: { q?: string; categoria?: string } = {}) {
return prisma.producto.findMany({
where: {
publicado: true,
...(q && { nombre: { contains: q, mode: 'insensitive' } }),
...(categoria && { categoria: { slug: categoria } })
},
include: { categoria: true },
orderBy: { creadoEn: 'desc' }
})
}
// cache: se ejecuta una vez por petición aunque lo llamen la página y generateMetadata
export const obtenerProducto = cache(async (slug: string) => {
return prisma.producto.findUnique({ where: { slug }, include: { categoria: true } })
})tsx
// app/productos/page.tsx
import Link from 'next/link'
import { listarProductos } from '@/lib/productos'
export const metadata = { title: 'Productos' }
type Props = { searchParams: Promise<{ q?: string }> }
export default async function Productos({ searchParams }: Props) {
const { q } = await searchParams
const productos = await listarProductos({ q })
return (
<main className="mx-auto max-w-4xl p-8">
<h1 className="mb-6 text-3xl font-bold">Productos</h1>
<form className="mb-6">
<input name="q" defaultValue={q} placeholder="Buscar…" className="w-full rounded border px-3 py-2" />
</form>
<ul className="grid gap-4 sm:grid-cols-2">
{productos.map((p) => (
<li key={p.id} className="rounded-xl border p-4">
<span className="text-xs uppercase text-gray-500">{p.categoria.nombre}</span>
<Link href={`/productos/${p.slug}`} className="block text-lg font-semibold hover:text-violet-700">
{p.nombre}
</Link>
{/* Decimal → número para mostrarlo */}
<p>${Number(p.precio).toFixed(2)}</p>
</li>
))}
</ul>
{productos.length === 0 && <p>No hay productos.</p>}
</main>
)
}Decimal y fechas hacia Client Components
Decimal de Prisma no se puede pasar como prop a un Client Component (no es serializable). Conviértelo antes: precio: Number(p.precio). Las fechas (Date) sí se pueden pasar.
Paso 5: CRUD con Server Actions
ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
// import { requerirAdmin } from '@/lib/autorizacion' // lección 11
const ProductoSchema = z.object({
nombre: z.string().trim().min(2, 'Mínimo 2 caracteres').max(120),
slug: z
.string()
.trim()
.toLowerCase()
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Sólo minúsculas, números y guiones'),
descripcion: z.string().trim().max(2000).optional(),
precio: z.coerce.number().positive('Debe ser mayor a 0'),
stock: z.coerce.number().int().min(0),
categoriaId: z.coerce.number().int().positive('Elige una categoría'),
publicado: z.preprocess((v) => v === 'on', z.boolean())
})
export type EstadoForm = {
errores?: Record<string, string[] | undefined>
mensaje?: string
}
function leerFormulario(formData: FormData) {
return ProductoSchema.safeParse({
nombre: formData.get('nombre'),
slug: formData.get('slug'),
descripcion: formData.get('descripcion') || undefined,
precio: formData.get('precio'),
stock: formData.get('stock'),
categoriaId: formData.get('categoriaId'),
publicado: formData.get('publicado')
})
}
export async function crearProducto(_: EstadoForm, formData: FormData): Promise<EstadoForm> {
// await requerirAdmin()
const r = leerFormulario(formData)
if (!r.success) return { errores: z.flattenError(r.error).fieldErrors, mensaje: 'Revisa los campos' }
try {
await prisma.producto.create({ data: r.data })
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return { errores: { slug: ['Ese slug ya existe'] } }
}
return { mensaje: 'Error al guardar. Intenta de nuevo.' }
}
revalidatePath('/admin/productos')
revalidatePath('/productos')
redirect('/admin/productos') // fuera del try/catch
}
export async function actualizarProducto(id: number, _: EstadoForm, formData: FormData): Promise<EstadoForm> {
// await requerirAdmin()
const r = leerFormulario(formData)
if (!r.success) return { errores: z.flattenError(r.error).fieldErrors, mensaje: 'Revisa los campos' }
try {
await prisma.producto.update({ where: { id }, data: r.data })
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return { errores: { slug: ['Ese slug ya existe'] } }
}
return { mensaje: 'Error al guardar.' }
}
revalidatePath('/admin/productos')
revalidatePath(`/productos/${r.data.slug}`)
redirect('/admin/productos')
}
export async function eliminarProducto(id: number) {
// await requerirAdmin()
await prisma.producto.delete({ where: { id } })
revalidatePath('/admin/productos')
revalidatePath('/productos')
}tsx
'use client'
import { useActionState } from 'react'
import type { EstadoForm } from './acciones'
type Valores = {
nombre?: string
slug?: string
descripcion?: string | null
precio?: number
stock?: number
categoriaId?: number
publicado?: boolean
}
type Props = {
accion: (estado: EstadoForm, formData: FormData) => Promise<EstadoForm>
categorias: { id: number; nombre: string }[]
valores?: Valores
textoBoton: string
}
export default function FormProducto({ accion, categorias, valores = {}, textoBoton }: Props) {
const [estado, formAction, pendiente] = useActionState(accion, {})
const err = (campo: string) => estado.errores?.[campo]?.[0]
return (
<form action={formAction} className="max-w-xl space-y-4">
{estado.mensaje && <p role="alert" className="rounded bg-red-50 p-3 text-red-700">{estado.mensaje}</p>}
<label className="block">
Nombre
<input name="nombre" defaultValue={valores.nombre} className="w-full rounded border px-3 py-2" />
{err('nombre') && <span className="text-sm text-red-600">{err('nombre')}</span>}
</label>
<label className="block">
Slug (URL)
<input name="slug" defaultValue={valores.slug} placeholder="cafe-chiapas" className="w-full rounded border px-3 py-2" />
{err('slug') && <span className="text-sm text-red-600">{err('slug')}</span>}
</label>
<label className="block">
Descripción
<textarea name="descripcion" defaultValue={valores.descripcion ?? ''} rows={3} className="w-full rounded border px-3 py-2" />
</label>
<div className="grid grid-cols-2 gap-4">
<label className="block">
Precio
<input name="precio" type="number" step="0.01" defaultValue={valores.precio} className="w-full rounded border px-3 py-2" />
{err('precio') && <span className="text-sm text-red-600">{err('precio')}</span>}
</label>
<label className="block">
Stock
<input name="stock" type="number" defaultValue={valores.stock ?? 0} className="w-full rounded border px-3 py-2" />
{err('stock') && <span className="text-sm text-red-600">{err('stock')}</span>}
</label>
</div>
<label className="block">
Categoría
<select name="categoriaId" defaultValue={valores.categoriaId ?? ''} className="w-full rounded border px-3 py-2">
<option value="">Selecciona…</option>
{categorias.map((c) => <option key={c.id} value={c.id}>{c.nombre}</option>)}
</select>
{err('categoriaId') && <span className="text-sm text-red-600">{err('categoriaId')}</span>}
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="publicado" defaultChecked={valores.publicado ?? true} />
Publicado
</label>
<button disabled={pendiente} className="rounded bg-violet-600 px-6 py-2 text-white disabled:opacity-60">
{pendiente ? 'Guardando…' : textoBoton}
</button>
</form>
)
}tsx
import Link from 'next/link'
import { prisma } from '@/lib/prisma'
import BotonEliminar from './BotonEliminar'
export default async function AdminProductos() {
const productos = await prisma.producto.findMany({
include: { categoria: { select: { nombre: true } } },
orderBy: { actualizado: 'desc' }
})
return (
<main className="mx-auto max-w-5xl p-8">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">Productos ({productos.length})</h1>
<Link href="/admin/productos/nuevo" className="rounded bg-violet-600 px-4 py-2 text-white">+ Nuevo</Link>
</div>
<table className="w-full text-left">
<thead>
<tr className="border-b text-sm text-gray-500">
<th className="p-2">Nombre</th><th>Categoría</th><th>Precio</th><th>Stock</th><th>Estado</th><th></th>
</tr>
</thead>
<tbody>
{productos.map((p) => (
<tr key={p.id} className="border-b">
<td className="p-2">{p.nombre}</td>
<td>{p.categoria.nombre}</td>
<td>${Number(p.precio).toFixed(2)}</td>
<td className={p.stock < 10 ? 'text-red-600' : ''}>{p.stock}</td>
<td>{p.publicado ? '✅' : '📝 Borrador'}</td>
<td className="space-x-3 text-right">
<Link href={`/admin/productos/${p.id}/editar`} className="text-violet-700">Editar</Link>
<BotonEliminar id={p.id} nombre={p.nombre} />
</td>
</tr>
))}
</tbody>
</table>
</main>
)
}tsx
'use client'
import { useTransition } from 'react'
import { eliminarProducto } from './acciones'
export default function BotonEliminar({ id, nombre }: { id: number; nombre: string }) {
const [pendiente, start] = useTransition()
return (
<button
disabled={pendiente}
className="text-red-600 disabled:opacity-50"
onClick={() => confirm(`¿Eliminar "${nombre}"?`) && start(() => eliminarProducto(id))}
>
{pendiente ? 'Eliminando…' : 'Eliminar'}
</button>
)
}tsx
import { prisma } from '@/lib/prisma'
import FormProducto from '../FormProducto'
import { crearProducto } from '../acciones'
export default async function NuevoProducto() {
const categorias = await prisma.categoria.findMany({ orderBy: { nombre: 'asc' } })
return (
<main className="mx-auto max-w-5xl p-8">
<h1 className="mb-6 text-2xl font-bold">Nuevo producto</h1>
<FormProducto accion={crearProducto} categorias={categorias} textoBoton="Crear producto" />
</main>
)
}tsx
import { notFound } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import FormProducto from '../../FormProducto'
import { actualizarProducto } from '../../acciones'
export default async function EditarProducto({ params }: { params: Promise<{ id: string }> }) {
const id = Number((await params).id)
if (!Number.isInteger(id)) notFound()
const [producto, categorias] = await Promise.all([
prisma.producto.findUnique({ where: { id } }),
prisma.categoria.findMany({ orderBy: { nombre: 'asc' } })
])
if (!producto) notFound()
// bind fija el id como primer argumento de la acción
const accion = actualizarProducto.bind(null, id)
return (
<main className="mx-auto max-w-5xl p-8">
<h1 className="mb-6 text-2xl font-bold">Editar: {producto.nombre}</h1>
<FormProducto
accion={accion}
categorias={categorias}
valores={{ ...producto, precio: Number(producto.precio) }}
textoBoton="Guardar cambios"
/>
</main>
)
}Despliegue con base de datos
En el build o al arrancar en producción, aplica las migraciones pendientes:
json
{
"scripts": {
"build": "prisma generate && next build",
"start": "next start",
"db:deploy": "prisma migrate deploy"
}
}- En Vercel, usa una base de datos en la nube (Neon, Supabase, Prisma Postgres) y configura
DATABASE_URL. Para entornos serverless, usa la URL con connection pooling que ofrece tu proveedor. - En un VPS, ejecuta
npx prisma migrate deployantes depm2 reload(ver Despliegue).
Buena práctica
- Centraliza las consultas en
lib/conimport 'server-only': así nunca acaban en el navegador. - Nunca uses
migrate deven producción (puede borrar datos); allí se usamigrate deploy. - Selecciona sólo los campos que necesitas (
select) en listados grandes.
Error común
Crear new PrismaClient() en cada archivo o en cada petición. Usa el singleton de lib/prisma.ts o verás el error "Too many connections".
Ejercicios
- Agrega a la página
/productosun filtro por categoría con enlaces (?categoria=cafe) usandolistarProductos({ categoria }). - Crea la página
/productos/[slug]que muestre el detalle conobtenerProducto,generateMetadataynotFound()si no existe o no está publicado.
Ver solución
tsx
// 1. app/productos/page.tsx (fragmento)
import Link from 'next/link'
import { prisma } from '@/lib/prisma'
import { listarProductos } from '@/lib/productos'
type Props = { searchParams: Promise<{ q?: string; categoria?: string }> }
export default async function Productos({ searchParams }: Props) {
const { q, categoria } = await searchParams
const [productos, categorias] = await Promise.all([
listarProductos({ q, categoria }),
prisma.categoria.findMany({ orderBy: { nombre: 'asc' } })
])
return (
<main className="mx-auto max-w-4xl p-8">
<nav className="mb-6 flex gap-3">
<Link href="/productos" className={!categoria ? 'font-bold' : ''}>Todas</Link>
{categorias.map((c) => (
<Link key={c.id} href={`/productos?categoria=${c.slug}`} className={categoria === c.slug ? 'font-bold' : ''}>
{c.nombre}
</Link>
))}
</nav>
<ul>{productos.map((p) => <li key={p.id}>{p.nombre}</li>)}</ul>
</main>
)
}tsx
// 2. app/productos/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { obtenerProducto } from '@/lib/productos'
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props) {
const p = await obtenerProducto((await params).slug)
return p ? { title: p.nombre, description: p.descripcion ?? undefined } : { title: 'No encontrado' }
}
export default async function Producto({ params }: Props) {
const p = await obtenerProducto((await params).slug)
if (!p || !p.publicado) notFound()
return (
<main className="mx-auto max-w-2xl p-8">
<span className="text-sm uppercase text-gray-500">{p.categoria.nombre}</span>
<h1 className="text-3xl font-bold">{p.nombre}</h1>
<p className="my-4">{p.descripcion}</p>
<p className="text-2xl font-bold">${Number(p.precio).toFixed(2)}</p>
<p className={p.stock > 0 ? 'text-green-700' : 'text-red-600'}>
{p.stock > 0 ? `${p.stock} disponibles` : 'Agotado'}
</p>
</main>
)
}Resumen
- Define modelos en
schema.prisma, crea tablas conprisma migrate devy datos conprisma db seed. - Usa un singleton de
PrismaClientenlib/prisma.tsconserver-only. - Lee datos directamente en Server Components; muta con Server Actions validadas y
revalidatePath. - Convierte
Decimala número antes de pasarlo a Client Components; usamigrate deployen producción.