Saltar al contenido principal
Volver al blog

Prisma con Next.js: modelos, migraciones y consultas optimizadas

Ray MartínRay Martín
11 min de lectura
Prisma con Next.js: modelos, migraciones y consultas optimizadas

Que es Prisma y por que usarlo

Prisma es un ORM (Object-Relational Mapping) de nueva generacion para Node.js y TypeScript que reemplaza a ORMs tradicionales como Sequelize o TypeORM con una experiencia de desarrollo radicalmente superior. En lugar de escribir SQL crudo o depender de query builders en tiempo de ejecucion, Prisma genera un cliente completamente tipado a partir de tu esquema de base de datos, proporcionandote autocompletado, verificacion de errores en tiempo de compilacion y patrones de acceso a datos intuitivos.

Cuando se combina con el App Router de Next.js, Prisma permite un flujo de trabajo full-stack muy potente: defines tu esquema de base de datos en un solo archivo, generas consultas type-safe y las usas directamente en Server Components, Route Handlers y Server Actions. No necesitas una capa API separada ni definiciones de tipos manuales: Prisma se encarga de todo.

Ventajas clave de Prisma frente a ORMs tradicionales:

  • Type safety: Cada consulta esta completamente tipada segun tu esquema. Si renombras una columna, TypeScript detecta cada referencia rota en tiempo de compilacion.
  • Cliente autogenerado: El Prisma Client se genera a partir de tu esquema, lo que significa que tu capa de acceso a datos siempre esta sincronizada con la estructura de tu base de datos.
  • Migraciones declarativas: Los cambios en el esquema se expresan de forma declarativa, y Prisma genera los archivos de migracion SQL automaticamente.
  • Agnostico de base de datos: Prisma soporta PostgreSQL, MySQL, SQLite, SQL Server, MongoDB y CockroachDB con la misma API.
  • Prisma Studio: Un explorador visual de base de datos para inspeccionar y editar datos durante el desarrollo.

Instalacion y configuracion inicial

Empieza instalando Prisma como dependencia de desarrollo y el Prisma Client como dependencia de produccion en tu proyecto Next.js:

bash
# Instalar CLI de Prisma y Client
npm install prisma --save-dev
npm install @prisma/client

# Inicializar Prisma con PostgreSQL como datasource
npx prisma init --datasource-provider postgresql

Este comando crea dos archivos: un archivo prisma/schema.prisma donde defines tus modelos de datos, y un archivo .env con una URL de conexion placeholder.

Actualiza el archivo .env con la URL de conexion real de tu base de datos:

bash
# .env
DATABASE_URL="postgresql://usuario:password@localhost:5432/midb?schema=public"

El archivo schema.prisma es el corazon de tu configuracion de Prisma. Contiene tres secciones: la configuracion del datasource, la configuracion del generador y tus modelos de datos:

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

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

// Los modelos van aqui

Importante: Nunca hagas commit de tu archivo .env al control de versiones. Anadelo a tu .gitignore y usa variables de entorno en tu plataforma de despliegue.

Definicion de modelos con relaciones

Los modelos de Prisma se mapean directamente a tablas de la base de datos. Cada campo en un modelo corresponde a una columna, y puedes definir relaciones entre modelos usando una sintaxis especial. Aqui tienes un ejemplo practico con modelos User, Post y Category:

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

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

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  avatar    String?
  role      Role     @default(USER)
  posts     Post[]
  profile   Profile?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("users")
}

model Profile {
  id      String  @id @default(cuid())
  bio     String?
  website String?
  userId  String  @unique
  user    User    @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@map("profiles")
}

model Post {
  id          String     @id @default(cuid())
  title       String
  slug        String     @unique
  content     String
  excerpt     String?
  published   Boolean    @default(false)
  authorId    String
  author      User       @relation(fields: [authorId], references: [id], onDelete: Cascade)
  categories  Category[]
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt

  @@index([authorId])
  @@index([slug])
  @@map("posts")
}

model Category {
  id    String @id @default(cuid())
  name  String @unique
  slug  String @unique
  posts Post[]

  @@map("categories")
}

enum Role {
  USER
  ADMIN
  EDITOR
}

Conceptos clave de este esquema:

  • @id marca la clave primaria. @default(cuid()) genera un ID unico resistente a colisiones.
  • @unique crea una restriccion de unicidad en la columna.
  • @relation define relaciones de clave foranea. El parametro fields especifica la columna local y references la columna destino.
  • @@index crea indices de base de datos para columnas consultadas frecuentemente.
  • @@map personaliza el nombre de la tabla en la base de datos (Prisma usa el nombre del modelo por defecto).
  • El campo Post[] en User es un campo de relacion virtual: no crea una columna pero te permite consultar los posts relacionados.
  • La relacion many-to-many implicita entre Post y Category es gestionada automaticamente por Prisma a traves de una tabla de union.

Migraciones: prisma migrate dev y deploy

Prisma Migrate traduce los cambios de tu esquema en archivos de migracion SQL. Durante el desarrollo, usa prisma migrate dev para crear y aplicar migraciones:

bash
# Crear y aplicar una nueva migracion
npx prisma migrate dev --name init

# Este comando hace tres cosas:
# 1. Genera un archivo de migracion SQL en prisma/migrations/
# 2. Aplica la migracion a tu base de datos
# 3. Regenera el Prisma Client

Cada migracion crea una carpeta con marca de tiempo que contiene un archivo migration.sql con las sentencias SQL crudas. Estos archivos deben ser commiteados al control de versiones para que tu equipo pueda reproducir el esquema de la base de datos.

Cuando modificas tu esquema (por ejemplo, anadiendo un nuevo campo), ejecuta migrate dev de nuevo:

typescript
// Anadir nuevos campos al modelo Post
model Post {
  id          String     @id @default(cuid())
  title       String
  slug        String     @unique
  content     String
  excerpt     String?
  coverImage  String?    // Nuevo campo
  readTime    Int?       // Nuevo campo
  published   Boolean    @default(false)
  authorId    String
  author      User       @relation(fields: [authorId], references: [id], onDelete: Cascade)
  categories  Category[]
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt

  @@index([authorId])
  @@index([slug])
  @@map("posts")
}
bash
# Generar migracion para los nuevos campos
npx prisma migrate dev --name add-post-cover-and-readtime

Para despliegues en produccion, usa prisma migrate deploy. Este comando aplica las migraciones pendientes sin generar nuevas:

bash
# Aplicar migraciones pendientes en produccion
npx prisma migrate deploy

Otros comandos utiles del CLI de Prisma:

bash
# Resetear la base de datos (elimina todos los datos y re-aplica migraciones)
npx prisma migrate reset

# Generar Prisma Client sin ejecutar migraciones
npx prisma generate

# Push de cambios del esquema directamente sin crear archivo de migracion (solo prototipado)
npx prisma db push

# Seed de la base de datos con datos iniciales
npx prisma db seed

Patron Singleton del Prisma Client para Next.js

En desarrollo, Next.js usa hot module reloading (HMR) que causa que los modulos se re-evaluen frecuentemente. Si instancias un nuevo Prisma Client en cada recarga del modulo, agotas rapidamente las conexiones de tu base de datos. La solucion es un patron singleton que reutiliza la misma instancia del cliente entre recargas.

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

Este patron almacena el Prisma Client en el objeto globalThis, que persiste entre recargas de HMR. En produccion, se crea un nuevo cliente normalmente ya que HMR no aplica. La configuracion de log habilita el logging de consultas durante el desarrollo para depuracion.

Importa el singleton donde necesites acceso a la base de datos:

typescript
import { prisma } from "@/lib/prisma";

Consejo: Si usas Prisma con herramientas de connection pooling como PgBouncer, anade ?pgbouncer=true a tu URL de conexion y configura la directUrl para migraciones. Consulta la seccion de connection pooling mas abajo para mas detalles.

Operaciones CRUD con Server Components y Route Handlers

Con el singleton de Prisma configurado, puedes consultar la base de datos directamente en Server Components, Server Actions y Route Handlers. No necesitas una capa API separada.

Leer datos en Server Components

typescript
// app/[locale]/blog/page.tsx
import { prisma } from "@/lib/prisma";

export default async function BlogPage() {
  const posts = await prisma.post.findMany({
    where: { published: true },
    include: {
      author: {
        select: { name: true, avatar: true },
      },
      categories: true,
    },
    orderBy: { createdAt: "desc" },
    take: 10,
  });

  return (
    <main>
      <h1>Blog</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>Por {post.author.name}</p>
            <p>{post.excerpt}</p>
            <div>
              {post.categories.map((cat) => (
                <span key={cat.id}>{cat.name}</span>
              ))}
            </div>
          </li>
        ))}
      </ul>
    </main>
  );
}

Crear datos con Server Actions

typescript
// app/[locale]/blog/actions.ts
"use server";

import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { z } from "zod";

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  excerpt: z.string().max(300).optional(),
  slug: z.string().regex(/^[a-z0-9-]+$/),
  categoryIds: z.array(z.string()).optional(),
});

export async function createPost(formData: FormData) {
  const rawData = {
    title: formData.get("title"),
    content: formData.get("content"),
    excerpt: formData.get("excerpt"),
    slug: formData.get("slug"),
  };

  const validated = CreatePostSchema.parse(rawData);

  const post = await prisma.post.create({
    data: {
      title: validated.title,
      slug: validated.slug,
      content: validated.content,
      excerpt: validated.excerpt,
      authorId: "current-user-id", // Reemplazar con autenticacion real
      categories: validated.categoryIds
        ? { connect: validated.categoryIds.map((id) => ({ id })) }
        : undefined,
    },
  });

  revalidatePath("/blog");
  return post;
}

CRUD con Route Handlers

typescript
// app/api/posts/route.ts
import { prisma } from "@/lib/prisma";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";

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 skip = (page - 1) * limit;

  const [posts, total] = await Promise.all([
    prisma.post.findMany({
      where: { published: true },
      skip,
      take: limit,
      orderBy: { createdAt: "desc" },
      include: {
        author: { select: { name: true, avatar: true } },
      },
    }),
    prisma.post.count({ where: { published: true } }),
  ]);

  return NextResponse.json({
    data: posts,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
    },
  });
}

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const validated = CreatePostSchema.parse(body);

    const post = await prisma.post.create({
      data: {
        ...validated,
        authorId: "current-user-id",
      },
    });

    return NextResponse.json(post, { status: 201 });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: "Validacion fallida", details: error.errors },
        { status: 400 }
      );
    }
    return NextResponse.json(
      { error: "Error interno del servidor" },
      { status: 500 }
    );
  }
}

Actualizar y eliminar registros

typescript
// app/api/posts/[id]/route.ts
import { prisma } from "@/lib/prisma";
import { NextRequest, NextResponse } from "next/server";

export async function PATCH(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const body = await request.json();

  const post = await prisma.post.update({
    where: { id },
    data: {
      title: body.title,
      content: body.content,
      published: body.published,
      categories: body.categoryIds
        ? { set: body.categoryIds.map((catId: string) => ({ id: catId })) }
        : undefined,
    },
  });

  return NextResponse.json(post);
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;

  await prisma.post.delete({ where: { id } });

  return NextResponse.json({ success: true });
}

Optimizacion de consultas

Prisma proporciona varias herramientas para optimizar tus consultas de base de datos. Entender estas opciones es critico para construir aplicaciones de alto rendimiento.

Select vs Include

Por defecto, Prisma devuelve todos los campos escalares de un modelo. Usa select para obtener solo los campos que necesitas, o include para cargar adicionalmente los modelos relacionados:

typescript
// select: Solo devuelve los campos especificados (reduce la transferencia de datos)
const postTitles = await prisma.post.findMany({
  select: {
    id: true,
    title: true,
    slug: true,
    createdAt: true,
    author: {
      select: { name: true },
    },
  },
});

// include: Devuelve todos los campos escalares MAS los modelos relacionados
const postsWithAuthor = await prisma.post.findMany({
  include: {
    author: true,
    categories: true,
  },
});

// select anidado dentro de include
const detailed = await prisma.post.findUnique({
  where: { slug: "mi-post" },
  include: {
    author: {
      select: {
        name: true,
        email: true,
        profile: {
          select: { bio: true },
        },
      },
    },
    categories: {
      select: { name: true, slug: true },
    },
  },
});

Consejo de rendimiento: Siempre prefiere select sobre include cuando solo necesitas campos especificos. Esto reduce la cantidad de datos transferidos desde la base de datos y acelera la serializacion.

Paginacion basada en cursor y por offset

typescript
// Paginacion por offset (simple pero mas lenta para grandes datasets)
const page = 2;
const pageSize = 20;

const posts = await prisma.post.findMany({
  skip: (page - 1) * pageSize,
  take: pageSize,
  orderBy: { createdAt: "desc" },
});

// Paginacion basada en cursor (mas rapida para grandes datasets)
const posts = await prisma.post.findMany({
  take: 20,
  skip: 1, // Saltar el cursor en si
  cursor: {
    id: lastPostId, // El ID del ultimo elemento de la pagina anterior
  },
  orderBy: { createdAt: "desc" },
});

Consultas raw para operaciones complejas

typescript
// Usa consultas raw cuando la API de consultas de Prisma no sea suficiente
const popularPosts = await prisma.$queryRaw`
  SELECT p.id, p.title, p.slug, COUNT(v.id) as view_count
  FROM posts p
  LEFT JOIN post_views v ON v.post_id = p.id
  WHERE p.published = true
  GROUP BY p.id
  ORDER BY view_count DESC
  LIMIT 10
`;

// Consultas parametrizadas para prevenir inyeccion SQL
const searchTerm = "prisma";
const results = await prisma.$queryRaw`
  SELECT * FROM posts
  WHERE title ILIKE ${'%' + searchTerm + '%'}
  AND published = true
  ORDER BY created_at DESC
`;

Trabajar con relaciones

Relaciones uno-a-muchos

typescript
// Crear un usuario con posts en una sola transaccion
const userWithPosts = await prisma.user.create({
  data: {
    email: "maria@ejemplo.com",
    name: "Maria Garcia",
    posts: {
      create: [
        {
          title: "Mi primer post",
          slug: "mi-primer-post",
          content: "Hola mundo!",
        },
        {
          title: "Mi segundo post",
          slug: "mi-segundo-post",
          content: "Otro post mas.",
        },
      ],
    },
  },
  include: { posts: true },
});

// Consultar posts de un usuario especifico
const userPosts = await prisma.post.findMany({
  where: { authorId: "user-id" },
  orderBy: { createdAt: "desc" },
});

Relaciones muchos-a-muchos

typescript
// Conectar categorias existentes a un post
const post = await prisma.post.update({
  where: { id: "post-id" },
  data: {
    categories: {
      connect: [
        { id: "category-1" },
        { id: "category-2" },
      ],
    },
  },
  include: { categories: true },
});

// Desconectar una categoria de un post
const updated = await prisma.post.update({
  where: { id: "post-id" },
  data: {
    categories: {
      disconnect: [{ id: "category-1" }],
    },
  },
});

// Reemplazar todas las categorias de un post (set reemplaza toda la relacion)
const replaced = await prisma.post.update({
  where: { id: "post-id" },
  data: {
    categories: {
      set: [{ id: "category-3" }, { id: "category-4" }],
    },
  },
});

// Consultar posts por categoria
const techPosts = await prisma.post.findMany({
  where: {
    categories: {
      some: { slug: "tecnologia" },
    },
  },
});

Transacciones

typescript
// Transaccion interactiva para operaciones complejas
const result = await prisma.$transaction(async (tx) => {
  // Crear una nueva categoria
  const category = await tx.category.create({
    data: { name: "Nueva Categoria", slug: "nueva-categoria" },
  });

  // Crear un post y conectarlo a la categoria
  const post = await tx.post.create({
    data: {
      title: "Ejemplo de transaccion",
      slug: "ejemplo-de-transaccion",
      content: "Este post fue creado en una transaccion.",
      authorId: "user-id",
      categories: { connect: { id: category.id } },
    },
  });

  // Actualizar estadisticas del usuario
  await tx.user.update({
    where: { id: "user-id" },
    data: { /* actualizar stats */ },
  });

  return { category, post };
});

// Si alguna operacion falla, todos los cambios se revierten

Prisma Studio para depuracion

Prisma Studio es un explorador visual de base de datos que se abre en tu navegador. Te permite ver, crear, actualizar y eliminar registros sin escribir codigo, algo invaluable durante el desarrollo y la depuracion.

bash
# Lanzar Prisma Studio
npx prisma studio

# Se abre en http://localhost:5555 por defecto

Funcionalidades de Prisma Studio:

  • Explorar todas las tablas y sus registros en una interfaz tipo hoja de calculo
  • Filtrar y ordenar registros por cualquier columna
  • Editar registros inline y guardar cambios directamente en la base de datos
  • Navegar relaciones haciendo clic en registros relacionados
  • Crear nuevos registros con una interfaz basada en formularios
  • Eliminar registros con dialogos de confirmacion

Aunque Prisma Studio es excelente para desarrollo, nunca lo uses en entornos de produccion. Para la administracion de bases de datos en produccion, usa herramientas de administracion adecuadas con controles de acceso y registro de auditoria.

Buenas practicas

Manejo de errores

typescript
import { Prisma } from "@prisma/client";

async function createUser(email: string, name: string) {
  try {
    const user = await prisma.user.create({
      data: { email, name },
    });
    return { success: true, data: user };
  } catch (error) {
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
      // P2002: Violacion de restriccion de unicidad
      if (error.code === "P2002") {
        return {
          success: false,
          error: "Ya existe un usuario con este email.",
        };
      }
      // P2025: Registro no encontrado
      if (error.code === "P2025") {
        return {
          success: false,
          error: "Registro no encontrado.",
        };
      }
    }
    // Re-lanzar errores inesperados
    throw error;
  }
}

Connection Pooling con PgBouncer

En entornos serverless como Vercel, cada invocacion de funcion puede crear una nueva conexion a la base de datos. Sin connection pooling, esto agota rapidamente el limite de conexiones de tu base de datos. Usa PgBouncer o un pooler gestionado como el connection pooler integrado de Supabase:

typescript
// prisma/schema.prisma
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")       // Conexion pooled para consultas
  directUrl = env("DIRECT_URL")         // Conexion directa para migraciones
}
bash
# .env
# Conexion pooled (via PgBouncer) para consultas de la aplicacion
DATABASE_URL="postgresql://user:pass@pooler.ejemplo.com:6543/mydb?pgbouncer=true"

# Conexion directa para migraciones (sin pasar por el pooler)
DIRECT_URL="postgresql://user:pass@db.ejemplo.com:5432/mydb"

La directUrl es utilizada por prisma migrate porque las migraciones requieren una conexion directa que soporte sentencias DDL y advisory locks, que no son compatibles con el modo de pooling de transacciones de PgBouncer.

Seeding de la base de datos

typescript
// prisma/seed.ts
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

async function main() {
  // Limpiar datos existentes
  await prisma.post.deleteMany();
  await prisma.category.deleteMany();
  await prisma.user.deleteMany();

  // Crear usuarios
  const admin = await prisma.user.create({
    data: {
      email: "admin@ejemplo.com",
      name: "Usuario Admin",
      role: "ADMIN",
      profile: {
        create: {
          bio: "Administrador de la plataforma",
          website: "https://ejemplo.com",
        },
      },
    },
  });

  // Crear categorias
  const categories = await Promise.all(
    ["Tecnologia", "Diseno", "Negocios"].map((name) =>
      prisma.category.create({
        data: { name, slug: name.toLowerCase() },
      })
    )
  );

  // Crear posts
  await prisma.post.create({
    data: {
      title: "Empezando con Prisma",
      slug: "empezando-con-prisma",
      content: "Una guia completa de Prisma ORM...",
      excerpt: "Aprende a usar Prisma con Next.js",
      published: true,
      authorId: admin.id,
      categories: {
        connect: [{ id: categories[0].id }],
      },
    },
  });

  console.log("Base de datos seeded correctamente");
}

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });
json
// package.json
{
  "prisma": {
    "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
  }
}
bash
# Ejecutar el script de seed
npx prisma db seed

Extensiones del Prisma Client

Las extensiones del Prisma Client te permiten anadir comportamiento personalizado a tus consultas. Aqui tienes un ejemplo que anade funcionalidad de logging y deteccion de consultas lentas:

typescript
// lib/prisma.ts
import { PrismaClient } from "@prisma/client";

const basePrisma = new PrismaClient();

export const prisma = basePrisma.$extends({
  query: {
    $allModels: {
      async findMany({ model, operation, args, query }) {
        const start = performance.now();
        const result = await query(args);
        const duration = performance.now() - start;

        if (duration > 1000) {
          console.warn(
            `Consulta lenta detectada: ${model}.${operation} tardo ${duration.toFixed(0)}ms`
          );
        }

        return result;
      },
    },
  },
});

Combinando estas buenas practicas (patron singleton, manejo correcto de errores, connection pooling, seeding y extensiones) construyes una capa de base de datos robusta y mantenible que escala con tu aplicacion Next.js.

Compartir:

Artículos relacionados