Crea un gestor de tareas sencillo con categorías usando Node.js y MongoDB
Aprende a organizar tareas por categoría con Node.js y MongoDB. Crea una API REST con endpoints para crear, listar, obtener, actualizar y eliminar, y filtro por categoría.
Una lista de tareas básica va de lujo al principio: añades tareas y las marcas como hechas cuando terminas. Pero a medida que crecen, se vuelve incómoda. Una solución muy útil es añadir categorías, para que cada tarea pertenezca a "Work", "Study", "Personal", etc., y puedas filtrar la lista por la categoría que te interese.
En este tutorial aprenderás a crear una API REST que te permita organizar tareas por categoría usando Node.js y MongoDB. Al final tendrás un servidor Node.js funcional con cinco endpoints: crear, listar, obtener, actualizar y eliminar, además de un filtro por categoría. También verás cómo aplicar validación de categorías usando el driver nativo de MongoDB, sin depender de un ODM como Mongoose.
Qué aprenderás
Cómo crear una API REST con Node.js, Express y MongoDB, incluyendo endpoints CRUD completos
Cómo validar la entrada, forzar categorías y gestionar con seguridad valores de ObjectId de forma eficaz
Cómo estructurar y probar tu API y luego conectarla a un frontend
Puedes encontrar el código completo de este tutorial en GitHub si prefieres clonarlo e ir leyendo sobre la marcha.
Requisitos previos
Antes de empezar, deberías tener:
Node.js 18+ instalado (npm se incluye por defecto)
MongoDB en local o un clúster gratuito de MongoDB Atlas
Postman o cualquier cliente HTTP
Conocimientos básicos de JavaScript y conceptos REST
Paso 1: prepara el proyecto
Vamos a construir nuestro gestor de tareas sencillo. Lo primero será crear una carpeta nueva e inicializar un proyecto de Node ejecutando estos comandos en la terminal:
mkdir task-manager-categories
cd task-manager-categories
npm init -yCon la tecnología de
A continuación, instalaremos cuatro dependencias de ejecución que necesita la app: express para el enrutado, el driver oficial de MongoDB, dotenv para cargar variables de entorno y, por último, helmet para las cabeceras de seguridad.
Para desarrollo, instala nodemon como dependencia de desarrollo con el siguiente comando para que el servidor se recargue automáticamente cuando cambien los archivos:
Ahora abre la carpeta del proyecto en tu IDE y actualiza la sección scripts en package.json para incluir lo siguiente. Así podrás iniciar el servidor con npm start o ejecutarlo en modo desarrollo con npm run dev:
"scripts":{"start":"node server.js","dev":"nodemon server.js"}Con la tecnología de
Esta es la estructura que iremos montando. Cada carpeta tiene un rol claro: db/ gestiona la conexión a la base de datos, lib/ contiene funciones de ayuda, middleware/ es para el middleware de Express y routes/ define los controladores de las peticiones. La carpeta public/ alojará un pequeño frontend que añadiremos después. Incluso en un proyecto pequeño, esta estructura hace que todo sea fácil de seguir y deja claro dónde debe ir el nuevo código. Si quieres, crea ya el árbol de carpetas y ve completándolo sobre la marcha, o sáltatelo y añade cada archivo según avancemos:
Para guardar y recuperar tareas, primero necesitamos conectar la app a MongoDB. Esto se hace con una cadena de conexión, que indica al driver cómo conectarse a tu base de datos. En lugar de ponerla a fuego en el código, es mejor guardarla en un archivo de entorno. Así mantienes fuera del repositorio los valores sensibles y te resulta más fácil cambiar de entorno.
Crea un archivo .env.example para documentar las variables necesarias y un .env con tus valores reales, así:
# Copia este archivo a `.env` y rellena los valores reales.# --- MongoDB local ---# Usa esto si ejecutas MongoDB en tu máquinaMONGO_URI=mongodb://127.0.0.1:27017
# --- MongoDB Atlas ---# Sustituye <username>, <password> y <cluster-url> por tus valores reales# Ejemplo: mongodb+srv://user:[email protected]/# MONGO_URI=mongodb+srv://<username>:<password>@<cluster-url>/?retryWrites=true&w=majorityDB_NAME=taskmanager
PORT=3000Con la tecnología de
Con la cadena de conexión lista, ya podemos usarla para configurar la conexión a MongoDB. Crea el archivo db/connect.js. Aquí inicializaremos el cliente de MongoDB y lo dejaremos disponible para el resto de la app:
const{ MongoClient }=require("mongodb");let client;let db;asyncfunctionconnectDB(){if(!process.env.MONGO_URI){thrownewError("MONGO_URI is not set. Check your .env file.");}
client =newMongoClient(process.env.MONGO_URI,{appName:"devrel-tutorial-javascript-crud-geeksforgeeks",});await client.connect();
db = client.db(process.env.DB_NAME||"taskmanager");await db.collection("tasks").createIndex({category:1});
console.log(`MongoDB connected (db: ${db.databaseName})`);}functiongetTasksCollection(){if(!db){thrownewError("Database not initialized. Call connectDB() first.");}return db.collection("tasks");}asyncfunctioncloseDB(){if(client)await client.close();}
module.exports ={ connectDB, getTasksCollection, closeDB };Con la tecnología de
Este archivo configura un único cliente de MongoDB que reutiliza el resto de la app. Solo quieres crearlo una vez, ya que el driver gestiona internamente el pool de conexiones. Crear un cliente nuevo en cada petición puede parecer inofensivo al principio, pero te traerá problemas de rendimiento muy pronto.
Paso 3: define la estructura de la tarea y la validación
A estas alturas, la app ya puede conectarse a MongoDB. Ahora necesitamos definir cómo es una tarea antes de empezar a guardar nada.
Aquí es donde usar el driver nativo de MongoDB se siente un poco diferente. No hay un archivo de esquema como con Mongoose. En su lugar, el "esquema" es simplemente la forma del objeto que insertas en la base de datos. Puede parecer algo laxo al principio, pero es útil porque te mantiene cerca de cómo funciona realmente MongoDB, sin capas de abstracción.
Aun así queremos validación. En lugar de repartir esa lógica por distintas rutas, la centralizaremos en un único sitio para que todo siga las mismas reglas. Crea un archivo llamado lib/taskDocument.js y añade el siguiente código:
classValidationErrorextendsError{constructor(message){super(message);this.name ='ValidationError';}}constALLOWED_CATEGORIES=['Work','Personal','Study','Other'];functionassertValidCategory(category){if(!ALLOWED_CATEGORIES.includes(category)){thrownewValidationError(`category must be one of: ${ALLOWED_CATEGORIES.join(', ')}`);}}functionbuildTaskDocument(body ={}){if(!body.title ||typeof body.title !=='string'||!body.title.trim()){thrownewValidationError('title is required and must be a non-empty string');}if(body.category !=null){assertValidCategory(body.category);}const now =newDate();return{title: body.title.trim(),description:typeof body.description ==='string'? body.description.trim():'',category: body.category !=null? body.category :'Other',completed:Boolean(body.completed),createdAt: now,updatedAt: now
};}functionbuildTaskUpdate(body ={}){const updates ={};if(typeof body.title ==='string'&& body.title.trim()){
updates.title = body.title.trim();}if(typeof body.description ==='string'){
updates.description = body.description.trim();}if(body.category !=null){assertValidCategory(body.category);
updates.category = body.category;}if(body.completed !=null){
updates.completed =Boolean(body.completed);}if(Object.keys(updates).length ===0){thrownewValidationError('no valid fields provided for update');}
updates.updatedAt =newDate();return updates;}
module.exports ={ALLOWED_CATEGORIES,
buildTaskDocument,
buildTaskUpdate,
ValidationError
};Con la tecnología de
Este archivo actúa como guardián de todo lo que entra en tu base de datos. Todas las peticiones de creación o actualización pasan por aquí, así que las reglas se aplican siempre en el mismo sitio.
Garantiza que las tareas tengan siempre la forma correcta, mantiene las categorías coherentes y evita sorpresas cuando empieces a hacer consultas. La clase personalizada ValidationError también te permite separar entradas inválidas de errores reales del servidor, para que tu API responda adecuadamente. Con esto en su sitio, el resto de la app puede mantenerse simple. Cada ruta se centra en su trabajo sabiendo que los datos ya son válidos. A continuación, conectaremos las rutas y empezaremos a guardar tareas en MongoDB.
Paso 4: gestiona ObjectId y la validación de peticiones
Ya sabemos cómo es una tarea; ahora veamos cómo la referenciamos. Siempre que una ruta incluya un parámetro :id, este llega como una cadena. Pero MongoDB espera un ObjectId. Si la cadena está mal formada, el driver lanza un error poco útil. En vez de manejarlo en cada ruta, lo centralizaremos con middleware para que todos los endpoints se comporten igual.
Para ello, crea un archivo llamado middleware/parseObjectId.js y añade lo siguiente:
const{ ObjectId }=require('mongodb');functionparseObjectId(req, res, next){const{ id }= req.params;if(!ObjectId.isValid(id)){return res.status(400).json({error:'invalid task id'});}
req.taskId =newObjectId(id);next();}
module.exports = parseObjectId;Con la tecnología de
Este middleware se ejecuta antes del manejador de la ruta. Comprueba que el id es válido, lo convierte en un ObjectId y lo adjunta a req.taskId. Así, cuando se ejecute la lógica de la ruta, siempre trabajarás con un ObjectId correcto, y las entradas erróneas se rechazan pronto con una respuesta 400. Ahora lo conectaremos a nuestras rutas para dejarlo todo listo.
Paso 5: crea las rutas de tareas (CRUD + filtrado)
A estas alturas, ya hemos hecho lo más importante. Hemos definido cómo debe ser una tarea válida y hemos resuelto cómo analizar y validar los IDs. Eso permite que los manejadores de rutas se centren en una sola cosa: hablar con la base de datos.
Vamos a crear las rutas de la API en un archivo llamado routes/tasks.js y añadir lo siguiente:
const express =require('express');const{ getTasksCollection }=require('../db/connect');const{ALLOWED_CATEGORIES,
buildTaskDocument,
buildTaskUpdate,
ValidationError
}=require('../lib/taskDocument');const parseObjectId =require('../middleware/parseObjectId');const router = express.Router();// POST /tasks
router.post('/',async(req, res, next)=>{try{const doc =buildTaskDocument(req.body);const result =awaitgetTasksCollection().insertOne(doc);
res.status(201).json({_id: result.insertedId,...doc });}catch(err){if(err instanceofValidationError){return res.status(400).json({error: err.message });}next(err);}});// GET /tasks (optionally ?category=Work)
router.get('/',async(req, res, next)=>{try{const{ category }= req.query;if(category &&!ALLOWED_CATEGORIES.includes(category)){return res.status(400).json({error:`category must be one of: ${ALLOWED_CATEGORIES.join(', ')}`});}const filter = category ?{ category }:{};const tasks =awaitgetTasksCollection().find(filter).sort({createdAt:-1}).toArray();
res.json(tasks);}catch(err){next(err);}});// GET /tasks/:id
router.get('/:id', parseObjectId,async(req, res, next)=>{try{const task =awaitgetTasksCollection().findOne({_id: req.taskId });if(!task)return res.status(404).json({error:'task not found'});
res.json(task);}catch(err){next(err);}});// PUT /tasks/:id
router.put('/:id', parseObjectId,async(req, res, next)=>{try{const updates =buildTaskUpdate(req.body);const result =awaitgetTasksCollection().findOneAndUpdate({_id: req.taskId },{$set: updates },{returnDocument:'after'});if(!result)return res.status(404).json({error:'task not found'});
res.json(result);}catch(err){if(err instanceofValidationError){return res.status(400).json({error: err.message });}next(err);}});// DELETE /tasks/:id
router.delete('/:id', parseObjectId,async(req, res, next)=>{try{const result =awaitgetTasksCollection().deleteOne({_id: req.taskId });if(result.deletedCount ===0){return res.status(404).json({error:'task not found'});}
res.status(204).end();}catch(err){next(err);}});
module.exports = router;Con la tecnología de
Cada ruta del código anterior sigue el mismo patrón: toma la entrada de la petición, la pasa por los helpers que creamos, llama a MongoDB y devuelve una respuesta. Con la validación y el análisis del ID ya resueltos, el código aquí se mantiene pequeño y predecible. En la práctica, funciona así:
La ruta POST construye una nueva tarea con buildTaskDocument antes de insertarla
Las rutas GET filtran opcionalmente por categoría y devuelven los resultados ordenados de más nuevo a más antiguo
La ruta PUT usa buildTaskUpdate, así que las actualizaciones parciales son seguras y consistentes
La ruta DELETE elimina una tarea por su ObjectId ya analizado
También hay algunos detalles interesantes en este código:
find() devuelve un cursor, no un array, así que encadenamos .toArray() tras ordenar para obtener los resultados
findOneAndUpdate con returnDocument: 'after' te devuelve el documento actualizado al instante
Devolvemos códigos HTTP adecuados: 201 para crear, 204 para eliminar y 400 o 404 cuando corresponde
Los errores inesperados se pasan a next(err) para gestionarlos en un único sitio en lugar de repetir lógica en cada ruta
Paso 6: conecta todas las piezas en el servidor
Ya tenemos todas las piezas: validación, rutas limpias y conexión a base de datos. Ahora solo falta conectarlo todo e iniciar el servidor. Crea un archivo server.js. Es el punto de entrada de la app, donde todo se une:
require('dotenv').config();const path =require('path');const express =require('express');const helmet =require('helmet');const{ connectDB, closeDB }=require('./db/connect');const tasksRouter =require('./routes/tasks');if(!process.env.MONGO_URI){
console.error('Fatal: MONGO_URI is not set. Copy .env.example to .env and fill it in.');
process.exit(1);}constPORT=Number(process.env.PORT)||3000;const app =express();
app.use(helmet());
app.use(express.json({limit:'100kb'}));
app.use(express.static(path.join(__dirname,'public')));
app.get('/health',(req, res)=>{
res.json({status:'ok',service:'task-manager-with-categories'});});
app.use('/tasks', tasksRouter);
app.use((req, res)=>{
res.status(404).json({error:'not found'});});
app.use((err, req, res, next)=>{if(err.type ==='entity.too.large'){return res.status(413).json({error:'payload too large'});}if(err.type ==='entity.parse.failed'){return res.status(400).json({error:'invalid JSON body'});}
console.error(err);
res.status(500).json({error:'internal server error'});});asyncfunctionstart(){try{awaitconnectDB();const server = app.listen(PORT,()=>{
console.log(`Server running on http://localhost:${PORT}`);});constshutdown=()=>{
console.log('\nShutting down gracefully...');
server.close(async()=>{awaitcloseDB();
process.exit(0);});setTimeout(()=> process.exit(1),10_000).unref();};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);}catch(err){
console.error('Failed to start server:', err);
process.exit(1);}}if(require.main === module){start();}
module.exports ={ app, start };Con la tecnología de
Este archivo lo deja todo bien atado. Comprueba que las variables de entorno estén definidas antes de ejecutar nada, aplica seguridad básica con helmet y limita el JSON entrante para que tu app no acepte cargas enormes por error. También sirve la carpeta public/, para que el frontend viva en la misma app sin necesitar un servidor aparte.
Todas las rutas se montan bajo /tasks y cualquier cosa que no coincida devuelve un limpio 404. Los errores se gestionan en un único sitio en lugar de repetirse en cada ruta, lo que mantiene la consistencia y facilita el mantenimiento. La conexión a la base de datos se establece antes de que el servidor empiece a escuchar, así que nunca aceptas peticiones antes de estar listo, y el apagado es ordenado, cerrando las conexiones correctamente.
Ahora arráncalo para comprobar que todo funciona con npm run dev. Si todo está bien conectado, verás algo como esto:
MongoDB connected (db: taskmanager)
Server running on http://localhost:3000
Con la tecnología de
¡Listo! Ya tenemos una API totalmente funcional, así que vamos a probarla.
Paso 7: prueba los endpoints de la API
Antes de construir una interfaz, conviene confirmar que cada endpoint funciona por separado. Esto facilita mucho el debug, porque puedes ver enseguida si el problema viene de la API o del frontend. Usaremos Postman (o cualquier cliente HTTP) y lanzaremos unas cuantas peticiones en orden. Cada una se apoya en los datos creados en el paso anterior.
Crea una tarea: Envía una petición POST con título, descripción y categoría. Si todo va bien, la API devuelve 201 Created con la nueva tarea y su _id generado.
POST http://localhost:3000/tasks
Content-Type: application/json
{"title":"Write GeeksForGeeks article",
"description":"First draft by Friday",
"category":"Work"}Con la tecnología de
Crea unas cuantas más: Lanza la misma petición POST con cuerpos distintos para tener datos con los que probar el listado y el filtrado. Con esto tendrás un dataset pequeño para consultar.
{"title":"Go for a run","category":"Personal"}{"title":"Read MongoDB docs","category":"Study"}{"title":"Buy groceries"}// category defaults to "Other"Con la tecnología de
Lista todas las tareas: Devuelve todas las tareas, ordenadas de la más reciente a la más antigua.
Actualiza una tarea: Usa el _id de una de las tareas que acabas de crear (puedes copiarlo de la respuesta del POST o del endpoint de listado). Devuelve la tarea actualizada. Solo necesitas enviar los campos que quieras cambiar, así que las actualizaciones parciales se mantienen simples.
PUT http://localhost:3000/tasks/<paste-task-id-here>
Content-Type: application/json
{"completed":true}Con la tecnología de
Elimina una tarea: Usa el _id de la tarea que quieras borrar. Devuelve 204 No Content. Si intentas obtener de nuevo esa tarea, recibirás un 404 con "task not found".
Con esto confirmas que todas las rutas se comportan como esperas. La API hace su trabajo, así que podemos pasar a construir una interfaz encima.
Paso 8: añade un frontend para interactuar con la API
Postman va genial para verificar la API, pero apetece verlo en una web real, así que añadiremos algo de código frontend a nuestra aplicación.
En lugar de pegar aquí archivos grandes de HTML y CSS, he subido el código a este repositorio de GitHub. Ve allí y copia el contenido de index.html, styles.css y favicon.svg en tu carpeta public/ del proyecto. Guarda los cambios y ve a localhost:3000. Luego pega el código de abajo en el archivo app.js:
const form = document.getElementById('task-form');const titleEl = document.getElementById('title');const descEl = document.getElementById('description');const catEl = document.getElementById('category');const errEl = document.getElementById('form-error');const listEl = document.getElementById('task-list');const emptyEl = document.getElementById('empty');const filterEl = document.getElementById('filter');const countEl = document.getElementById('task-count');asyncfunctionapi(method, path, body){const res =awaitfetch(path,{
method,headers: body ?{'Content-Type':'application/json'}:{},body: body ?JSON.stringify(body):undefined});if(res.status ===204)returnnull;const data =await res.json();if(!res.ok)thrownewError(data.error ||`Request failed (${res.status})`);return data;}constfetchTasks=(category)=>api('GET','/tasks'+(category ?`?category=${encodeURIComponent(category)}`:''));constcreateTask=(body)=>api('POST','/tasks', body);constupdateTask=(id, body)=>api('PUT',`/tasks/${id}`, body);constdeleteTask=(id)=>api('DELETE',`/tasks/${id}`);functionupdateCount(tasks){const active = tasks.filter((t)=>!t.completed).length;const total = tasks.length;if(total ===0){
countEl.textContent ='';return;}const scope = filterEl.value ?` in ${filterEl.value}`:'';
countEl.textContent =`${total} tasks${scope} · ${active} active`;}functionrenderTask(task){const li = document.createElement('li');
li.className ='task'+(task.completed ?' completed':'');const checkbox = document.createElement('input');
checkbox.type ='checkbox';
checkbox.checked = task.completed;
checkbox.addEventListener('change',()=>updateTask(task._id,{completed: checkbox.checked }).then(refresh));const title = document.createElement('div');
title.textContent = task.title;const badge = document.createElement('span');
badge.textContent = task.category;const del = document.createElement('button');
del.textContent ='Delete';
del.onclick=()=>deleteTask(task._id).then(refresh);
li.append(checkbox, title, badge, del);return li;}functionrender(tasks){
listEl.innerHTML ='';
emptyEl.classList.toggle('hidden', tasks.length >0);for(const task of tasks){
listEl.appendChild(renderTask(task));}updateCount(tasks);}asyncfunctionrefresh(){try{const tasks =awaitfetchTasks(filterEl.value);render(tasks);}catch(e){
errEl.textContent = e.message;}}
form.addEventListener('submit',async(e)=>{
e.preventDefault();
errEl.textContent ='';const title = titleEl.value.trim();if(!title){
errEl.textContent ='Title is required.';return;}try{awaitcreateTask({
title,description: descEl.value.trim(),category: catEl.value
});
form.reset();awaitrefresh();}catch(e){
errEl.textContent = e.message;}});
filterEl.addEventListener('change', refresh);refresh();Con la tecnología de
Este JavaScript es donde todo vuelve a conectarse con la API que construiste. Cada función mapea a una de tus rutas, lo que mantiene el frontend fácil de seguir. Todas las peticiones pasan por un único helper api(), así que manejas los errores en un solo sitio en vez de repetir lógica por toda la app.
El filtrado funciona pasando una query string como ?category=Work, que se integra directamente con la lógica del backend que añadiste. Tras crear, actualizar o eliminar una tarea, la app vuelve a obtener la lista para mantener la UI sincronizada. El contador de tareas es un detalle pequeño, pero hace que la app se sienta más viva mientras la usas.
Reinicia ahora el servidor con npm run dev y abre http://localhost:3000 para probar el gestor de tareas. Añade unas cuantas, actualízalas, cambia entre categorías y borra alguna. A estas alturas, frontend y backend están conectados y funcionando de extremo a extremo.
Resumen
¡Enhorabuena! Has creado desde cero una API completa para un gestor de tareas con Node.js, Express y MongoDB. Has aplicado validación, mantenido rutas concisas y conectado todo en un sistema funcional. Lo más importante: te has mantenido cerca de cómo funciona realmente MongoDB, sin ocultarlo tras abstracciones. A partir de aquí puedes empezar a evolucionar la app para llevarla a producción.
Ideas clave
Usar middleware y centralizar la validación mantiene tu código limpio y predecible.
El driver nativo de MongoDB te da control sin abstracciones innecesarias.
Una API bien estructurada es fácil de ampliar hasta una app lista para producción.
FAQs
¿Necesito Mongoose para crear una API con MongoDB?⌄
No. Este tutorial usa el driver nativo de MongoDB, que te da más control y mantiene todo ligero. Puedes añadir Mongoose más adelante si quieres abstracciones de esquema.
¿Cómo valido datos sin un esquema?⌄
Puedes centralizar la validación en funciones helper (como buildTaskDocument), garantizando que todas las rutas apliquen las mismas reglas antes de escribir en la base de datos.
¿Qué pasa si paso un ObjectId no válido?⌄
El middleware lo rechaza de forma temprana con una respuesta 400, evitando que MongoDB lance errores confusos.
Selecciona tu primer proyecto SQL, o el siguiente, para practicar tus habilidades actuales en SQL, desarrollar otras nuevas y crear un portafolio profesional excepcional.
Explora 13 proyectos prácticos de AWS para todos los niveles. Mejora tus conocimientos sobre la nube con aplicaciones prácticas del mundo real y la orientación de expertos.
Aprende algunas posibilidades de modelado de datos que incluyen documentos anidados al diseñar un sistema de gestión de contenidos (CMS) o una aplicación de blog.
Esta guía completa te enseña los fundamentos del backend con Python. Aprende conceptos básicos, frameworks y buenas prácticas para empezar a crear aplicaciones web.
Aprende a crear, conectar, gestionar, consultar y proteger tu base de datos Azure SQL. Esta guía paso a paso cubre todo lo esencial para una configuración óptima de la base de datos.
Una lista de tareas básica va de lujo al principio: añades tareas y las marcas como hechas cuando terminas. Pero a medida que crecen, se vuelve incómoda. Una solución muy útil es añadir categorías, para que cada tarea pertenezca a "Work", "Study", "Personal", etc., y puedas filtrar la lista por la categoría que te interese.
En este tutorial aprenderás a crear una API REST que te permita organizar tareas por categoría usando Node.js y MongoDB. Al final tendrás un servidor Node.js funcional con cinco endpoints: crear, listar, obtener, actualizar y eliminar, además de un filtro por categoría. También verás cómo aplicar validación de categorías usando el driver nativo de MongoDB, sin depender de un ODM como Mongoose.
Qué aprenderás
Puedes encontrar el código completo de este tutorial en GitHub si prefieres clonarlo e ir leyendo sobre la marcha.
Requisitos previos
Antes de empezar, deberías tener:
Paso 1: prepara el proyecto
Vamos a construir nuestro gestor de tareas sencillo. Lo primero será crear una carpeta nueva e inicializar un proyecto de Node ejecutando estos comandos en la terminal:
A continuación, instalaremos cuatro dependencias de ejecución que necesita la app: express para el enrutado, el driver oficial de MongoDB, dotenv para cargar variables de entorno y, por último, helmet para las cabeceras de seguridad.
Para desarrollo, instala nodemon como dependencia de desarrollo con el siguiente comando para que el servidor se recargue automáticamente cuando cambien los archivos:
Ahora abre la carpeta del proyecto en tu IDE y actualiza la sección scripts en package.json para incluir lo siguiente. Así podrás iniciar el servidor con npm start o ejecutarlo en modo desarrollo con npm run dev:
Esta es la estructura que iremos montando. Cada carpeta tiene un rol claro: db/ gestiona la conexión a la base de datos, lib/ contiene funciones de ayuda, middleware/ es para el middleware de Express y routes/ define los controladores de las peticiones. La carpeta public/ alojará un pequeño frontend que añadiremos después. Incluso en un proyecto pequeño, esta estructura hace que todo sea fácil de seguir y deja claro dónde debe ir el nuevo código. Si quieres, crea ya el árbol de carpetas y ve completándolo sobre la marcha, o sáltatelo y añade cada archivo según avancemos:
Paso 2: conecta con MongoDB
Para guardar y recuperar tareas, primero necesitamos conectar la app a MongoDB. Esto se hace con una cadena de conexión, que indica al driver cómo conectarse a tu base de datos. En lugar de ponerla a fuego en el código, es mejor guardarla en un archivo de entorno. Así mantienes fuera del repositorio los valores sensibles y te resulta más fácil cambiar de entorno.
Crea un archivo .env.example para documentar las variables necesarias y un .env con tus valores reales, así:
Con la cadena de conexión lista, ya podemos usarla para configurar la conexión a MongoDB. Crea el archivo db/connect.js. Aquí inicializaremos el cliente de MongoDB y lo dejaremos disponible para el resto de la app:
Este archivo configura un único cliente de MongoDB que reutiliza el resto de la app. Solo quieres crearlo una vez, ya que el driver gestiona internamente el pool de conexiones. Crear un cliente nuevo en cada petición puede parecer inofensivo al principio, pero te traerá problemas de rendimiento muy pronto.
Paso 3: define la estructura de la tarea y la validación
A estas alturas, la app ya puede conectarse a MongoDB. Ahora necesitamos definir cómo es una tarea antes de empezar a guardar nada.
Aquí es donde usar el driver nativo de MongoDB se siente un poco diferente. No hay un archivo de esquema como con Mongoose. En su lugar, el "esquema" es simplemente la forma del objeto que insertas en la base de datos. Puede parecer algo laxo al principio, pero es útil porque te mantiene cerca de cómo funciona realmente MongoDB, sin capas de abstracción.
Aun así queremos validación. En lugar de repartir esa lógica por distintas rutas, la centralizaremos en un único sitio para que todo siga las mismas reglas. Crea un archivo llamado lib/taskDocument.js y añade el siguiente código:
Este archivo actúa como guardián de todo lo que entra en tu base de datos. Todas las peticiones de creación o actualización pasan por aquí, así que las reglas se aplican siempre en el mismo sitio.
Garantiza que las tareas tengan siempre la forma correcta, mantiene las categorías coherentes y evita sorpresas cuando empieces a hacer consultas. La clase personalizada ValidationError también te permite separar entradas inválidas de errores reales del servidor, para que tu API responda adecuadamente. Con esto en su sitio, el resto de la app puede mantenerse simple. Cada ruta se centra en su trabajo sabiendo que los datos ya son válidos. A continuación, conectaremos las rutas y empezaremos a guardar tareas en MongoDB.
Paso 4: gestiona ObjectId y la validación de peticiones
Ya sabemos cómo es una tarea; ahora veamos cómo la referenciamos. Siempre que una ruta incluya un parámetro :id, este llega como una cadena. Pero MongoDB espera un ObjectId. Si la cadena está mal formada, el driver lanza un error poco útil. En vez de manejarlo en cada ruta, lo centralizaremos con middleware para que todos los endpoints se comporten igual.
Para ello, crea un archivo llamado middleware/parseObjectId.js y añade lo siguiente:
Este middleware se ejecuta antes del manejador de la ruta. Comprueba que el id es válido, lo convierte en un ObjectId y lo adjunta a req.taskId. Así, cuando se ejecute la lógica de la ruta, siempre trabajarás con un ObjectId correcto, y las entradas erróneas se rechazan pronto con una respuesta 400. Ahora lo conectaremos a nuestras rutas para dejarlo todo listo.
Paso 5: crea las rutas de tareas (CRUD + filtrado)
A estas alturas, ya hemos hecho lo más importante. Hemos definido cómo debe ser una tarea válida y hemos resuelto cómo analizar y validar los IDs. Eso permite que los manejadores de rutas se centren en una sola cosa: hablar con la base de datos.
Vamos a crear las rutas de la API en un archivo llamado routes/tasks.js y añadir lo siguiente:
Cada ruta del código anterior sigue el mismo patrón: toma la entrada de la petición, la pasa por los helpers que creamos, llama a MongoDB y devuelve una respuesta. Con la validación y el análisis del ID ya resueltos, el código aquí se mantiene pequeño y predecible. En la práctica, funciona así:
También hay algunos detalles interesantes en este código:
Paso 6: conecta todas las piezas en el servidor
Ya tenemos todas las piezas: validación, rutas limpias y conexión a base de datos. Ahora solo falta conectarlo todo e iniciar el servidor. Crea un archivo server.js. Es el punto de entrada de la app, donde todo se une:
Este archivo lo deja todo bien atado. Comprueba que las variables de entorno estén definidas antes de ejecutar nada, aplica seguridad básica con helmet y limita el JSON entrante para que tu app no acepte cargas enormes por error. También sirve la carpeta public/, para que el frontend viva en la misma app sin necesitar un servidor aparte.
Todas las rutas se montan bajo /tasks y cualquier cosa que no coincida devuelve un limpio 404. Los errores se gestionan en un único sitio en lugar de repetirse en cada ruta, lo que mantiene la consistencia y facilita el mantenimiento. La conexión a la base de datos se establece antes de que el servidor empiece a escuchar, así que nunca aceptas peticiones antes de estar listo, y el apagado es ordenado, cerrando las conexiones correctamente.
Ahora arráncalo para comprobar que todo funciona con npm run dev. Si todo está bien conectado, verás algo como esto:
¡Listo! Ya tenemos una API totalmente funcional, así que vamos a probarla.
Paso 7: prueba los endpoints de la API
Antes de construir una interfaz, conviene confirmar que cada endpoint funciona por separado. Esto facilita mucho el debug, porque puedes ver enseguida si el problema viene de la API o del frontend. Usaremos Postman (o cualquier cliente HTTP) y lanzaremos unas cuantas peticiones en orden. Cada una se apoya en los datos creados en el paso anterior.
Crea una tarea: Envía una petición POST con título, descripción y categoría. Si todo va bien, la API devuelve 201 Created con la nueva tarea y su _id generado.
Crea unas cuantas más: Lanza la misma petición POST con cuerpos distintos para tener datos con los que probar el listado y el filtrado. Con esto tendrás un dataset pequeño para consultar.
Lista todas las tareas: Devuelve todas las tareas, ordenadas de la más reciente a la más antigua.
Filtra por categoría: Devuelve solo las tareas de Work. El índice que añadiste antes mantiene esta consulta rápida a medida que crecen los datos.
Actualiza una tarea: Usa el _id de una de las tareas que acabas de crear (puedes copiarlo de la respuesta del POST o del endpoint de listado). Devuelve la tarea actualizada. Solo necesitas enviar los campos que quieras cambiar, así que las actualizaciones parciales se mantienen simples.
Elimina una tarea: Usa el _id de la tarea que quieras borrar. Devuelve 204 No Content. Si intentas obtener de nuevo esa tarea, recibirás un 404 con "task not found".
Con esto confirmas que todas las rutas se comportan como esperas. La API hace su trabajo, así que podemos pasar a construir una interfaz encima.
Paso 8: añade un frontend para interactuar con la API
Postman va genial para verificar la API, pero apetece verlo en una web real, así que añadiremos algo de código frontend a nuestra aplicación.
En lugar de pegar aquí archivos grandes de HTML y CSS, he subido el código a este repositorio de GitHub. Ve allí y copia el contenido de index.html, styles.css y favicon.svg en tu carpeta public/ del proyecto. Guarda los cambios y ve a localhost:3000. Luego pega el código de abajo en el archivo app.js:
Este JavaScript es donde todo vuelve a conectarse con la API que construiste. Cada función mapea a una de tus rutas, lo que mantiene el frontend fácil de seguir. Todas las peticiones pasan por un único helper api(), así que manejas los errores en un solo sitio en vez de repetir lógica por toda la app.
El filtrado funciona pasando una query string como ?category=Work, que se integra directamente con la lógica del backend que añadiste. Tras crear, actualizar o eliminar una tarea, la app vuelve a obtener la lista para mantener la UI sincronizada. El contador de tareas es un detalle pequeño, pero hace que la app se sienta más viva mientras la usas.
Reinicia ahora el servidor con npm run dev y abre http://localhost:3000 para probar el gestor de tareas. Añade unas cuantas, actualízalas, cambia entre categorías y borra alguna. A estas alturas, frontend y backend están conectados y funcionando de extremo a extremo.
Resumen
¡Enhorabuena! Has creado desde cero una API completa para un gestor de tareas con Node.js, Express y MongoDB. Has aplicado validación, mantenido rutas concisas y conectado todo en un sistema funcional. Lo más importante: te has mantenido cerca de cómo funciona realmente MongoDB, sin ocultarlo tras abstracciones. A partir de aquí puedes empezar a evolucionar la app para llevarla a producción.
Ideas clave