CRUD con MySQL desde Node.js
Serie: Desarrollo de Interfaces
Curso: Desarrollo de Interfaces 1
Capítulo 21: CRUD con MySQL desde Node.js
Capítulo anterior: Crear una base de datos y tablas en MySQL
Capítulo siguiente: Consultas preparadas, seguridad y validación en backend
Ya tenemos la ruta completa para llegar hasta aquí. Primero trabajamos datos en memoria, luego persistencia con JSON, después consumo de APIs, bases de datos relacionales y creación de tablas en MySQL. Ahora sí: conectaremos Node.js con MySQL para construir un CRUD real.
La idea principal es mantener el contrato que la interfaz ya entiende: rutas HTTP que devuelven JSON. Lo que cambia está por dentro del servidor. En vez de leer y escribir un archivo, ejecutaremos consultas SQL sobre una base de datos.
Qué aprenderás hoy
- Conectar Node.js con MySQL usando
mysql2/promise. - Crear un pool de conexiones.
- Construir rutas CRUD con Express.
- Usar consultas preparadas con parámetros.
- Devolver respuestas JSON útiles para una interfaz.
- Separar mínimamente conexión, consultas y rutas.
La interfaz no necesita saber si el backend guarda datos en JSON o MySQL. Si el contrato de la API se mantiene, podemos mejorar la persistencia sin rehacer la pantalla.
Estructura del proyecto
Usaremos una estructura simple, suficiente para aprender sin perdernos en arquitectura.
crud-mysql-node/
src/
db.js
server.js
.env
package.json
db.js tendrá la conexión. server.js tendrá las rutas. Más adelante podremos separar modelos, servicios y controladores, pero hoy lo mantendremos claro y directo.
Instalar dependencias
Necesitamos Express para crear la API, MySQL2 para conectarnos a MySQL y dotenv para leer variables de entorno desde un archivo .env.
npm init -y
npm install express mysql2 dotenv
Luego podemos ajustar package.json para usar módulos modernos:
{
"type": "module",
"scripts": {
"start": "node src/server.js"
},
"dependencies": {
"dotenv": "^16.0.0",
"express": "^4.18.0",
"mysql2": "^3.0.0"
}
}
Configurar variables de entorno
La conexión a la base de datos no debería estar escrita directamente dentro del código. Es mejor usar variables de entorno.
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=
DB_NAME=di_projects
PORT=3000
En tu laptop puede que el usuario sea root sin contraseña. En hosting o producción, normalmente tendrás un usuario y contraseña específicos. La idea es que el código no cambie: cambian las variables.
No subas contraseñas reales al repositorio. En proyectos reales, .env debe estar en .gitignore.
Crear el pool de conexiones
Un pool administra varias conexiones reutilizables. En vez de abrir una conexión nueva por cada solicitud, el pool permite trabajar de forma más eficiente.
import 'dotenv/config';
import mysql from 'mysql2/promise';
export const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
Este archivo no ejecuta consultas todavía. Solo prepara una conexión compartida para que el servidor pueda usarla.
Crear el servidor Express
Ahora configuramos Express y activamos express.json() para recibir datos enviados desde la interfaz.
import express from 'express';
import { pool } from './db.js';
const app = express();
const port = process.env.PORT || 3000;
app.use(express.json());
app.get('/', function (request, response) {
response.json({ message: 'API de proyectos funcionando' });
});
app.listen(port, function () {
console.log('Servidor iniciado en http://localhost:' + port);
});
Si ejecutas npm start, deberías tener una API disponible en http://localhost:3000.
Read: listar proyectos
La primera operación será listar proyectos junto con el nombre del curso. Usaremos INNER JOIN para combinar projects y courses.
app.get('/projects', async function (request, response) {
const [rows] = await pool.execute(`
SELECT
projects.id,
projects.title,
projects.team_name,
projects.status,
courses.name AS course_name
FROM projects
INNER JOIN courses ON courses.id = projects.course_id
ORDER BY projects.created_at DESC
`);
response.json(rows);
});
La interfaz recibirá un arreglo de objetos. Eso significa que puede seguir renderizando tarjetas, listas o tablas sin conocer los detalles internos de MySQL.
Read: obtener un proyecto por id
Para consultar un solo proyecto, usamos un parámetro de ruta. Aquí ya conviene usar una consulta preparada con ?.
app.get('/projects/:id', async function (request, response) {
const id = Number(request.params.id);
const [rows] = await pool.execute(
`SELECT id, course_id, title, team_name, summary, status
FROM projects
WHERE id = ?`,
[id]
);
if (rows.length === 0) {
response.status(404).json({ message: 'Proyecto no encontrado' });
return;
}
response.json(rows[0]);
});
El signo ? no es decoración. Representa un valor que MySQL2 enviará de forma parametrizada. Eso evita construir consultas pegando texto manualmente.
Create: registrar un proyecto
Para crear un proyecto recibimos datos desde el cuerpo de la solicitud. Usaremos POST /projects.
app.post('/projects', async function (request, response) {
const { course_id, title, team_name, summary, status } = request.body;
const [result] = await pool.execute(
`INSERT INTO projects (course_id, title, team_name, summary, status)
VALUES (?, ?, ?, ?, ?)`,
[course_id, title, team_name, summary || null, status || 'draft']
);
response.status(201).json({
id: result.insertId,
course_id,
title,
team_name,
summary,
status: status || 'draft'
});
});
result.insertId contiene el identificador generado por MySQL. La interfaz puede usarlo si necesita mostrar el nuevo registro o redirigir a una pantalla de detalle.
Update: modificar un proyecto
Para actualizar usamos PUT /projects/:id. La consulta cambia campos específicos y registra una fecha de actualización.
app.put('/projects/:id', async function (request, response) {
const id = Number(request.params.id);
const { course_id, title, team_name, summary, status } = request.body;
const [result] = await pool.execute(
`UPDATE projects
SET course_id = ?,
title = ?,
team_name = ?,
summary = ?,
status = ?,
updated_at = NOW()
WHERE id = ?`,
[course_id, title, team_name, summary || null, status, id]
);
if (result.affectedRows === 0) {
response.status(404).json({ message: 'Proyecto no encontrado' });
return;
}
response.json({ message: 'Proyecto actualizado' });
});
affectedRows nos indica si realmente se modificó alguna fila. Si es cero, probablemente el id no existe.
Delete: eliminar un proyecto
Para eliminar, usamos DELETE /projects/:id. Igual que antes, debemos revisar si la operación afectó una fila.
app.delete('/projects/:id', async function (request, response) {
const id = Number(request.params.id);
const [result] = await pool.execute(
'DELETE FROM projects WHERE id = ?',
[id]
);
if (result.affectedRows === 0) {
response.status(404).json({ message: 'Proyecto no encontrado' });
return;
}
response.json({ message: 'Proyecto eliminado' });
});
En una aplicación real, a veces no eliminamos físicamente registros. Podemos usar un campo como deleted_at o active. Pero para aprender CRUD, esta versión directa es suficiente.
Manejo básico de errores
Si algo falla en la consulta, no queremos que la API se quede sin responder. Una forma inicial es envolver cada ruta con try/catch.
app.get('/courses', async function (request, response) {
try {
const [rows] = await pool.execute(
'SELECT id, name, code FROM courses ORDER BY name'
);
response.json(rows);
} catch (error) {
console.error(error);
response.status(500).json({ message: 'Error al consultar cursos' });
}
});
Más adelante podemos crear un middleware central de errores. Hoy basta con entender la idea: la API debe responder de forma clara incluso cuando algo sale mal.
Separar responsabilidades
Si todas las consultas viven dentro de server.js, el archivo crece rápido. Una mejora natural es separar funciones de acceso a datos.
export async function findAllProjects(pool) {
const [rows] = await pool.execute(`
SELECT projects.id, projects.title, projects.team_name,
projects.status, courses.name AS course_name
FROM projects
INNER JOIN courses ON courses.id = projects.course_id
ORDER BY projects.created_at DESC
`);
return rows;
}
Después la ruta solo llama a la función:
app.get('/projects', async function (request, response) {
const projects = await findAllProjects(pool);
response.json(projects);
});
Este pequeño cambio prepara el terreno para hablar de capas, servicios y POO más adelante. No usamos clases todavía, pero ya estamos separando responsabilidades.
Errores comunes
- Escribir usuario y contraseña directamente en el código.
- Construir SQL concatenando valores recibidos desde la interfaz.
- No revisar
affectedRowsen operaciones de actualización o eliminación. - No devolver estados HTTP adecuados como
201,404o500. - Mezclar toda la lógica en un solo archivo sin separar conexión, rutas y consultas.
- Responder solo con mensajes genéricos que no ayudan a la interfaz.
Primero haz que el CRUD funcione. Luego refactoriza. Aprender backend también consiste en mejorar la estructura cuando ya entiendes el flujo completo.
Reto práctico
Construye una API CRUD para la tabla projects usando Node.js, Express y MySQL. Debe listar, consultar por id, crear, actualizar y eliminar proyectos. Todas las consultas con datos externos deben usar parámetros.
Variante extra: conecta esta API con una interfaz HTML usando fetch(). La pantalla debe mostrar estados de carga, error, lista vacía y operación exitosa.
Conclusión
Con este capítulo, el proyecto deja de ser un prototipo con datos sueltos y se convierte en una aplicación conectada a una base de datos real. La interfaz habla con una API. La API procesa solicitudes. MySQL guarda y consulta información estructurada.
Este es un punto muy importante para Desarrollo de Interfaces: una pantalla profesional no vive sola. Necesita datos, estados, validación, errores y persistencia. Entender el flujo completo ayuda a diseñar interfaces más realistas y a conversar mejor con equipos backend.
En el siguiente capítulo profundizaremos en consultas preparadas, seguridad y validación en backend. Ahí veremos cómo proteger mejor la API antes de pensar en organización más avanzada, servicios y POO.