Resumen ejecutivo
En este tutorial construís desde cero una API REST de tareas con Node.js y Express, con los cinco endpoints básicos: listar, ver uno, crear, editar y borrar.
Vas a aprender a organizar rutas, leer datos en JSON, validar lo que llega y responder con los códigos de estado HTTP correctos.
Primero guardamos los datos en memoria para entender el flujo, y después los pasamos a una base de datos SQLite para que no se pierdan.
Terminado y subido a GitHub con un buen README, es un proyecto backend válido para tu portfolio.
Casi todas las aplicaciones que usás a diario, desde una app de delivery hasta tu home banking, se comunican con un servidor a través de una API. Saber construir una es la habilidad base de cualquier desarrollador backend, y Node.js con Express es una de las combinaciones más usadas para aprenderlo: es JavaScript, tiene muchísima documentación y te permite tener algo funcionando en pocos minutos. Si todavía no tenés claro qué es Node.js, empezá por Node.js desde cero. Y si querés el concepto de API explicado sin código, leé API REST explicadas para no técnicos.
Qué vas a construir
Una API para gestionar una lista de tareas. Cada tarea tiene un id, un titulo y un campo completada. Los endpoints serán:
GET /tareas: lista todas las tareas.
GET /tareas/:id: devuelve una tarea.
POST /tareas: crea una tarea.
PUT /tareas/:id: actualiza una tarea.
DELETE /tareas/:id: borra una tarea.
Paso 1: preparar el proyecto
Necesitás tener Node.js instalado (la documentación oficial de Node.js explica cómo). Luego, en una terminal:
mkdir api-tareas
cd api-tareas
npm init -y
npm install express
mkdir api-tareas
cd api-tareas
npm init -y
npm install express
mkdir api-tareas
cd api-tareas
npm init -y
npm install express
mkdir api-tareas
cd api-tareas
npm init -y
npm install express
Esto crea un archivo package.json y descarga Express. Creá un archivo llamado index.js.
Paso 2: tu primer servidor con Express
Siguiendo la estructura del ejemplo inicial de Express, escribí en index.js:
const express = require('express');
const app = express();
const PORT = 3000;
app.use(express.json());
app.get('/', (req, res) => {
res.json({ mensaje: 'API de tareas funcionando' });
});
app.listen(PORT, () => {
console.log(`Servidor en http://localhost:${PORT}`);
});const express = require('express');
const app = express();
const PORT = 3000;
app.use(express.json());
app.get('/', (req, res) => {
res.json({ mensaje: 'API de tareas funcionando' });
});
app.listen(PORT, () => {
console.log(`Servidor en http://localhost:${PORT}`);
});const express = require('express');
const app = express();
const PORT = 3000;
app.use(express.json());
app.get('/', (req, res) => {
res.json({ mensaje: 'API de tareas funcionando' });
});
app.listen(PORT, () => {
console.log(`Servidor en http://localhost:${PORT}`);
});const express = require('express');
const app = express();
const PORT = 3000;
app.use(express.json());
app.get('/', (req, res) => {
res.json({ mensaje: 'API de tareas funcionando' });
});
app.listen(PORT, () => {
console.log(`Servidor en http://localhost:${PORT}`);
});Ejecutá node index.js y abrí http://localhost:3000 en el navegador. Si ves el mensaje en JSON, tu servidor funciona. La línea app.use(express.json()) le dice a Express que lea el cuerpo de las peticiones en formato JSON; sin ella, no vas a poder recibir datos.
Paso 3: los datos en memoria
Para entender el flujo sin sumar una base de datos todavía, guardamos las tareas en un arreglo. Agregá debajo de app.use:
let tareas = [
{ id: 1, titulo: 'Aprender Express', completada: false },
{ id: 2, titulo: 'Crear mi primera API', completada: false }
];
let siguienteId = 3;let tareas = [
{ id: 1, titulo: 'Aprender Express', completada: false },
{ id: 2, titulo: 'Crear mi primera API', completada: false }
];
let siguienteId = 3;let tareas = [
{ id: 1, titulo: 'Aprender Express', completada: false },
{ id: 2, titulo: 'Crear mi primera API', completada: false }
];
let siguienteId = 3;let tareas = [
{ id: 1, titulo: 'Aprender Express', completada: false },
{ id: 2, titulo: 'Crear mi primera API', completada: false }
];
let siguienteId = 3;Ojo: estos datos se borran cada vez que reiniciás el servidor. Lo resolvemos en el paso 8.
Paso 4: endpoints para leer (GET)
app.get('/tareas', (req, res) => {
res.json(tareas);
});
app.get('/tareas/:id', (req, res) => {
const id = Number(req.params.id);
const tarea = tareas.find(t => t.id === id);
if (!tarea) {
return res.status(404).json({ error: 'Tarea no encontrada' });
}
res.json(tarea);
});app.get('/tareas', (req, res) => {
res.json(tareas);
});
app.get('/tareas/:id', (req, res) => {
const id = Number(req.params.id);
const tarea = tareas.find(t => t.id === id);
if (!tarea) {
return res.status(404).json({ error: 'Tarea no encontrada' });
}
res.json(tarea);
});app.get('/tareas', (req, res) => {
res.json(tareas);
});
app.get('/tareas/:id', (req, res) => {
const id = Number(req.params.id);
const tarea = tareas.find(t => t.id === id);
if (!tarea) {
return res.status(404).json({ error: 'Tarea no encontrada' });
}
res.json(tarea);
});app.get('/tareas', (req, res) => {
res.json(tareas);
});
app.get('/tareas/:id', (req, res) => {
const id = Number(req.params.id);
const tarea = tareas.find(t => t.id === id);
if (!tarea) {
return res.status(404).json({ error: 'Tarea no encontrada' });
}
res.json(tarea);
});El :id en la ruta es un parámetro: Express lo deja disponible en req.params. La guía de rutas de Express explica todas las variantes. Fijate que si la tarea no existe respondemos con el código 404, no con un 200 vacío.
Paso 5: crear una tarea (POST) con validación
app.post('/tareas', (req, res) => {
const { titulo } = req.body;
if (!titulo || typeof titulo !== 'string') {
return res.status(400).json({ error: 'El campo titulo es obligatorio' });
}
const nueva = { id: siguienteId++, titulo, completada: false };
tareas.push(nueva);
res.status(201).json(nueva);
});app.post('/tareas', (req, res) => {
const { titulo } = req.body;
if (!titulo || typeof titulo !== 'string') {
return res.status(400).json({ error: 'El campo titulo es obligatorio' });
}
const nueva = { id: siguienteId++, titulo, completada: false };
tareas.push(nueva);
res.status(201).json(nueva);
});app.post('/tareas', (req, res) => {
const { titulo } = req.body;
if (!titulo || typeof titulo !== 'string') {
return res.status(400).json({ error: 'El campo titulo es obligatorio' });
}
const nueva = { id: siguienteId++, titulo, completada: false };
tareas.push(nueva);
res.status(201).json(nueva);
});app.post('/tareas', (req, res) => {
const { titulo } = req.body;
if (!titulo || typeof titulo !== 'string') {
return res.status(400).json({ error: 'El campo titulo es obligatorio' });
}
const nueva = { id: siguienteId++, titulo, completada: false };
tareas.push(nueva);
res.status(201).json(nueva);
});Dos detalles que separan una API prolija de una improvisada: validamos que llegue el título (si no, respondemos 400) y, si todo sale bien, devolvemos 201, que significa «recurso creado».
Paso 6: editar y borrar (PUT y DELETE)
app.put('/tareas/:id', (req, res) => {
const tarea = tareas.find(t => t.id === Number(req.params.id));
if (!tarea) {
return res.status(404).json({ error: 'Tarea no encontrada' });
}
const { titulo, completada } = req.body;
if (titulo !== undefined) tarea.titulo = titulo;
if (completada !== undefined) tarea.completada = Boolean(completada);
res.json(tarea);
});
app.delete('/tareas/:id', (req, res) => {
const existe = tareas.some(t => t.id === Number(req.params.id));
if (!existe) {
return res.status(404).json({ error: 'Tarea no encontrada' });
}
tareas = tareas.filter(t => t.id !== Number(req.params.id));
res.status(204).send();
});app.put('/tareas/:id', (req, res) => {
const tarea = tareas.find(t => t.id === Number(req.params.id));
if (!tarea) {
return res.status(404).json({ error: 'Tarea no encontrada' });
}
const { titulo, completada } = req.body;
if (titulo !== undefined) tarea.titulo = titulo;
if (completada !== undefined) tarea.completada = Boolean(completada);
res.json(tarea);
});
app.delete('/tareas/:id', (req, res) => {
const existe = tareas.some(t => t.id === Number(req.params.id));
if (!existe) {
return res.status(404).json({ error: 'Tarea no encontrada' });
}
tareas = tareas.filter(t => t.id !== Number(req.params.id));
res.status(204).send();
});app.put('/tareas/:id', (req, res) => {
const tarea = tareas.find(t => t.id === Number(req.params.id));
if (!tarea) {
return res.status(404).json({ error: 'Tarea no encontrada' });
}
const { titulo, completada } = req.body;
if (titulo !== undefined) tarea.titulo = titulo;
if (completada !== undefined) tarea.completada = Boolean(completada);
res.json(tarea);
});
app.delete('/tareas/:id', (req, res) => {
const existe = tareas.some(t => t.id === Number(req.params.id));
if (!existe) {
return res.status(404).json({ error: 'Tarea no encontrada' });
}
tareas = tareas.filter(t => t.id !== Number(req.params.id));
res.status(204).send();
});app.put('/tareas/:id', (req, res) => {
const tarea = tareas.find(t => t.id === Number(req.params.id));
if (!tarea) {
return res.status(404).json({ error: 'Tarea no encontrada' });
}
const { titulo, completada } = req.body;
if (titulo !== undefined) tarea.titulo = titulo;
if (completada !== undefined) tarea.completada = Boolean(completada);
res.json(tarea);
});
app.delete('/tareas/:id', (req, res) => {
const existe = tareas.some(t => t.id === Number(req.params.id));
if (!existe) {
return res.status(404).json({ error: 'Tarea no encontrada' });
}
tareas = tareas.filter(t => t.id !== Number(req.params.id));
res.status(204).send();
});El 204 indica que la operación salió bien y no hay contenido para devolver.
Paso 7: probar la API
Las peticiones GET se prueban desde el navegador, pero para POST, PUT y DELETE necesitás una herramienta como Postman, Insomnia, la extensión Thunder Client de VS Code o la terminal con curl:
curl -X POST http:
-H "Content-Type: application/json" \
-d '{"titulo": "Probar mi API"}'curl -X POST http:
-H "Content-Type: application/json" \
-d '{"titulo": "Probar mi API"}'curl -X POST http:
-H "Content-Type: application/json" \
-d '{"titulo": "Probar mi API"}'curl -X POST http:
-H "Content-Type: application/json" \
-d '{"titulo": "Probar mi API"}'Probá también los casos de error: crear una tarea sin título o pedir un id que no existe. Si te responde 400 y 404, vas bien.
Códigos de estado que usaste
200 OK: todo salió bien y hay datos en la respuesta.
201 Created: se creó un recurso nuevo.
204 No Content: salió bien, sin contenido para devolver.
400 Bad Request: los datos enviados son inválidos.
404 Not Found: el recurso no existe.
La referencia de códigos de estado HTTP de MDN tiene la lista completa en español.
Paso 8: conectar una base de datos con SQLite
Para que las tareas no se pierdan al reiniciar, las guardamos en SQLite, una base de datos que vive en un solo archivo y no requiere instalar un servidor. Instalá la librería better-sqlite3:
npm install better-sqlite3
npm install better-sqlite3
npm install better-sqlite3
npm install better-sqlite3
Reemplazá el arreglo en memoria por esta configuración al principio de index.js:
const Database = require('better-sqlite3');
const db = new Database('tareas.db');
db.exec(`CREATE TABLE IF NOT EXISTS tareas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
completada INTEGER NOT NULL DEFAULT 0
)`);const Database = require('better-sqlite3');
const db = new Database('tareas.db');
db.exec(`CREATE TABLE IF NOT EXISTS tareas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
completada INTEGER NOT NULL DEFAULT 0
)`);const Database = require('better-sqlite3');
const db = new Database('tareas.db');
db.exec(`CREATE TABLE IF NOT EXISTS tareas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
completada INTEGER NOT NULL DEFAULT 0
)`);const Database = require('better-sqlite3');
const db = new Database('tareas.db');
db.exec(`CREATE TABLE IF NOT EXISTS tareas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
completada INTEGER NOT NULL DEFAULT 0
)`);Y adaptá los endpoints para leer y escribir en la tabla. Por ejemplo, listar y crear quedan así:
app.get('/tareas', (req, res) => {
const filas = db.prepare('SELECT * FROM tareas').all();
res.json(filas);
});
app.post('/tareas', (req, res) => {
const { titulo } = req.body;
if (!titulo || typeof titulo !== 'string') {
return res.status(400).json({ error: 'El campo titulo es obligatorio' });
}
const info = db.prepare('INSERT INTO tareas (titulo) VALUES (?)').run(titulo);
const nueva = db.prepare('SELECT * FROM tareas WHERE id = ?').get(info.lastInsertRowid);
res.status(201).json(nueva);
});app.get('/tareas', (req, res) => {
const filas = db.prepare('SELECT * FROM tareas').all();
res.json(filas);
});
app.post('/tareas', (req, res) => {
const { titulo } = req.body;
if (!titulo || typeof titulo !== 'string') {
return res.status(400).json({ error: 'El campo titulo es obligatorio' });
}
const info = db.prepare('INSERT INTO tareas (titulo) VALUES (?)').run(titulo);
const nueva = db.prepare('SELECT * FROM tareas WHERE id = ?').get(info.lastInsertRowid);
res.status(201).json(nueva);
});app.get('/tareas', (req, res) => {
const filas = db.prepare('SELECT * FROM tareas').all();
res.json(filas);
});
app.post('/tareas', (req, res) => {
const { titulo } = req.body;
if (!titulo || typeof titulo !== 'string') {
return res.status(400).json({ error: 'El campo titulo es obligatorio' });
}
const info = db.prepare('INSERT INTO tareas (titulo) VALUES (?)').run(titulo);
const nueva = db.prepare('SELECT * FROM tareas WHERE id = ?').get(info.lastInsertRowid);
res.status(201).json(nueva);
});app.get('/tareas', (req, res) => {
const filas = db.prepare('SELECT * FROM tareas').all();
res.json(filas);
});
app.post('/tareas', (req, res) => {
const { titulo } = req.body;
if (!titulo || typeof titulo !== 'string') {
return res.status(400).json({ error: 'El campo titulo es obligatorio' });
}
const info = db.prepare('INSERT INTO tareas (titulo) VALUES (?)').run(titulo);
const nueva = db.prepare('SELECT * FROM tareas WHERE id = ?').get(info.lastInsertRowid);
res.status(201).json(nueva);
});El signo ? es un parámetro: la librería inserta el valor de forma segura y evita inyecciones SQL. Con la misma lógica adaptás GET /tareas/:id (con SELECT ... WHERE id = ?), PUT (con UPDATE) y DELETE; en los dos últimos, la propiedad changes del resultado te dice si existía la tarea para responder 404 si vale cero. Reiniciá el servidor y comprobá que las tareas siguen ahí.
Cuando el proyecto crezca, podés pasar a una base como PostgreSQL y usar un ORM como Prisma, que te deja trabajar con bases SQL desde JavaScript. Otras mejoras que suman mucho: separar las rutas en archivos, usar variables de entorno para el puerto y agregar un middleware de manejo de errores.
Cómo sumarlo a tu portfolio
Subí el proyecto a GitHub con un README que explique qué hace la API, cómo instalarla, la lista de endpoints con ejemplos de petición y respuesta, y qué aprendiste. Con la base de datos conectada, si además lo publicás en un servicio gratuito de hosting, tenés un proyecto que podés mostrar en una entrevista. Más ideas en proyectos backend con Node.js para tu portafolio.
Curso recomendado de Coderhouse
Si querés pasar de este primer ejemplo a construir servidores completos, la carrera de Desarrollo Backend te enseña Node.js, bases de datos SQL y NoSQL, autenticación y buenas prácticas para trabajar en proyectos reales.
Preguntas frecuentes
¿Necesito saber JavaScript antes de hacer este tutorial? Sí, al menos lo básico: variables, funciones, arreglos y objetos. Con eso podés seguir todos los pasos.
¿Express sigue siendo una buena opción para aprender? Sí. Es simple, muy documentado y muchísimas empresas lo usan. Lo que aprendas te sirve después para otros frameworks.
¿Qué diferencia hay entre PUT y PATCH? En teoría, PUT reemplaza el recurso completo y PATCH modifica solo algunos campos. Muchas APIs usan PUT para ambos casos, como en este ejemplo.
¿Cómo protejo mi API para que no cualquiera pueda borrar datos? Con autenticación, por ejemplo usando tokens. Es un buen segundo proyecto una vez que domines lo básico.