Skip to content

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.prisma donde 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 postgresql
env
# .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 datos

Paso 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 = prisma

Paso 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 deploy antes de pm2 reload (ver Despliegue).

Buena práctica

  • Centraliza las consultas en lib/ con import 'server-only': así nunca acaban en el navegador.
  • Nunca uses migrate dev en producción (puede borrar datos); allí se usa migrate 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 ​

  1. Agrega a la página /productos un filtro por categoría con enlaces (?categoria=cafe) usando listarProductos({ categoria }).
  2. Crea la página /productos/[slug] que muestre el detalle con obtenerProducto, generateMetadata y notFound() 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 con prisma migrate dev y datos con prisma db seed.
  • Usa un singleton de PrismaClient en lib/prisma.ts con server-only.
  • Lee datos directamente en Server Components; muta con Server Actions validadas y revalidatePath.
  • Convierte Decimal a número antes de pasarlo a Client Components; usa migrate deploy en producción.

Academia Web — aprende, copia y construye.