Corso
Una semplice lista di cose da fare funziona benissimo all’inizio: aggiungi qualche attività e la chiudi quando hai finito. Ma quando le attività aumentano, diventa scomoda da usare. Una soluzione è aggiungere la categorizzazione, così ogni attività può appartenere a categorie come Lavoro, Studio, Personale, ecc., e puoi filtrare l’elenco in base alla categoria che ti interessa.
In questo tutorial imparerai a creare una REST API che ti permette di organizzare le attività per categoria usando Node.js e MongoDB. Alla fine avrai un server Node.js funzionante con cinque endpoint: create, list, get, update e delete, insieme a un filtro per categoria. Vedrai anche come applicare la validazione delle categorie usando il driver nativo di MongoDB, senza affidarti a un ODM come Mongoose.
Cosa imparerai
- Come costruire una REST API con Node.js, Express e MongoDB, includendo endpoint CRUD completi
- Come validare l’input, imporre le categorie e gestire in sicurezza i valori ObjectId in modo efficace
- Come strutturare e testare la tua API e poi collegarla a un frontend
Puoi trovare il codice completo di questo tutorial su GitHub se preferisci clonarlo e seguire leggendo man mano.
Prerequisiti
Prima di iniziare, dovresti avere:
- Node.js 18+ installato (npm è incluso di default)
- MongoDB in esecuzione in locale, oppure un cluster gratuito su MongoDB Atlas
- Postman o qualsiasi client HTTP
- Conoscenze di base di JavaScript e dei concetti REST
Passo 1: Imposta il progetto
Procediamo a costruire il nostro semplice task manager. La prima cosa da fare è creare una nuova cartella e inizializzare un progetto Node eseguendo i comandi qui sotto nel terminale:
mkdir task-manager-categories
cd task-manager-categories
npm init -y
Poi installeremo le quattro dipendenze runtime necessarie all’app: express per il routing, il driver ufficiale MongoDB, dotenv per caricare le variabili d’ambiente e infine helmet per gli header di sicurezza.
npm install express mongodb dotenv helmet
Per lo sviluppo, installa nodemon come dipendenza di sviluppo con il comando qui sotto, così il server si ricarica automaticamente quando i file cambiano:
npm install --save-dev nodemon
Poi apri la cartella del progetto nel tuo IDE e aggiorna la sezione scripts in package.json includendo quanto segue. Questo ti permetterà di avviare il server con npm start o eseguirlo in modalità sviluppo con npm run dev:
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
Ecco la struttura che costruiremo passo dopo passo. Ogni cartella ha un ruolo chiaro: db/ gestisce la connessione al database, lib/ contiene funzioni di supporto, middleware/ è per i middleware di Express e routes/ definisce gli handler delle richieste. La cartella public/ conterrà un piccolo frontend che aggiungeremo dopo. Anche in un progetto piccolo, questa struttura mantiene tutto facile da seguire e chiarisce dove inserire nuovo codice. Sentiti libero di creare ora l’alberatura delle cartelle e riempirla strada facendo, oppure salta questo passaggio e aggiungi ogni file man mano che avanziamo:
task-manager-categories/
├── db/
│ └── connect.js
├── lib/
│ └── taskDocument.js
├── middleware/
│ └── parseObjectId.js
├── routes/
│ └── tasks.js
├── public/
│ ├── index.html
│ ├── styles.css
│ └── app.js
├── .env
├── .env.example
├── .gitignore
├── package.json
└── server.js
Passo 2: Connettiti a MongoDB
Per salvare e recuperare le attività, dobbiamo prima collegare la nostra app a MongoDB. Questo avviene tramite una stringa di connessione, che dice al driver MongoDB come connettersi al tuo database. Invece di inserirla hardcoded nel codice, è meglio tenerla in un file di ambiente. Così tieni i valori sensibili fuori dal codice e semplifichi il passaggio tra ambienti.
Crea un file .env.example per documentare le variabili richieste e un file .env con i tuoi valori reali, in questo modo:
# Copy this file to `.env` and fill in real values.
# --- Local MongoDB ---
# Use this if you're running MongoDB locally
MONGO_URI=mongodb://127.0.0.1:27017
# --- MongoDB Atlas ---
# Replace <username>, <password>, and <cluster-url> with your actual values
# Example: mongodb+srv://user:pass@cluster0.abcde.mongodb.net/
# MONGO_URI=mongodb+srv://<username>:<password>@<cluster-url>/?retryWrites=true&w=majority
DB_NAME=taskmanager
PORT=3000
Ora che abbiamo una stringa di connessione, possiamo usarla per configurare la connessione a MongoDB. Crea un file db/connect.js. Qui inizializzeremo il client MongoDB e lo renderemo disponibile al resto dell’app:
const { MongoClient } = require("mongodb");
let client;
let db;
async function connectDB() {
if (!process.env.MONGO_URI) {
throw new Error("MONGO_URI is not set. Check your .env file.");
}
client = new MongoClient(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})`);
}
function getTasksCollection() {
if (!db) {
throw new Error("Database not initialized. Call connectDB() first.");
}
return db.collection("tasks");
}
async function closeDB() {
if (client) await client.close();
}
module.exports = { connectDB, getTasksCollection, closeDB };
Questo file imposta un singolo client MongoDB riutilizzabile dal resto dell’app. Vuoi crearlo una sola volta, perché il driver gestisce già il connection pooling sotto il cofano. Creare un nuovo client a ogni richiesta può sembrare accettabile all’inizio, ma causerà problemi di performance piuttosto in fretta.
Passo 3: Definisci la struttura e la validazione delle attività
A questo punto, l’app può connettersi a MongoDB. Ora dobbiamo definire come è fatta un’attività prima di iniziare a salvare qualsiasi cosa.
Qui l’uso del driver nativo di MongoDB inizia a sembrare un po’ diverso. Non c’è un file di schema come avresti con Mongoose. Invece, lo “schema” è semplicemente la forma dell’oggetto che inserisci nel database. Può sembrare un po’ lasco all’inizio, ma in realtà è utile perché resti vicino a ciò che MongoDB fa davvero, senza nulla nascosto dietro astrazioni.
Vogliamo comunque la validazione. Quindi, invece di spargere quella logica tra diverse route, la terremo in un punto solo così tutto segue le stesse regole. Crea un file chiamato lib/taskDocument.js e aggiungi il codice qui sotto:
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
}
}
const ALLOWED_CATEGORIES = ['Work', 'Personal', 'Study', 'Other'];
function assertValidCategory(category) {
if (!ALLOWED_CATEGORIES.includes(category)) {
throw new ValidationError(
`category must be one of: ${ALLOWED_CATEGORIES.join(', ')}`
);
}
}
function buildTaskDocument(body = {}) {
if (!body.title || typeof body.title !== 'string' || !body.title.trim()) {
throw new ValidationError('title is required and must be a non-empty string');
}
if (body.category != null) {
assertValidCategory(body.category);
}
const now = new Date();
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
};
}
function buildTaskUpdate(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) {
throw new ValidationError('no valid fields provided for update');
}
updates.updatedAt = new Date();
return updates;
}
module.exports = {
ALLOWED_CATEGORIES,
buildTaskDocument,
buildTaskUpdate,
ValidationError
};
Questo file funge da guardiano per tutto ciò che entra nel tuo database. Ogni richiesta di creazione o aggiornamento passa di qui, così hai un unico punto che applica le regole.
Si assicura che le attività abbiano sempre la forma corretta, mantiene coerenti le categorie ed evita sorprese quando inizi a interrogare i dati. La classe personalizzata ValidationError ti offre anche un modo pulito per distinguere l’input errato dai veri problemi del server, così la tua API può rispondere in modo adeguato. Con questo in posizione, il resto dell’app può rimanere semplice. Ogni route può concentrarsi sul suo compito, sapendo che i dati ricevuti sono già validi. Nel prossimo passo collegheremo le route e inizieremo a salvare attività su MongoDB.
Passo 4: Gestisci ObjectId e la validazione delle richieste
Ora che sappiamo com’è fatta un’attività, occupiamoci di come farvi riferimento. Ogni volta che una route include un parametro :id, questo arriva come una semplice stringa. Ma MongoDB si aspetta un ObjectId. Se la stringa è malformata, il driver lancia un errore poco utile. Invece di gestirlo in ogni route, lo centralizzeremo con un middleware, così ogni endpoint si comporta allo stesso modo.
Per farlo, crea un file chiamato middleware/parseObjectId.js e aggiungi quanto segue:
const { ObjectId } = require('mongodb');
function parseObjectId(req, res, next) {
const { id } = req.params;
if (!ObjectId.isValid(id)) {
return res.status(400).json({ error: 'invalid task id' });
}
req.taskId = new ObjectId(id);
next();
}
module.exports = parseObjectId;
Questo middleware gira prima del tuo route handler. Controlla che l’id sia valido, lo converte in un ObjectId e lo aggiunge a req.taskId. In questo modo, quando la logica della route viene eseguita, lavori sempre con un ObjectId corretto e gli input errati vengono rifiutati subito con una chiara risposta 400. Nel prossimo passo lo collegheremo alle route e inizieremo a mettere insieme tutto.
Passo 5: Crea le route delle attività (CRUD + filtro)
A questo punto, il grosso è già fatto. Abbiamo definito com’è un’attività valida e gestito come analizzare e validare gli ID. Questo significa che i route handler possono concentrarsi su una cosa: parlare con il database.
Procediamo a costruire le vere route dell’API creando un file chiamato routes/tasks.js e aggiungendovi quanto segue:
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 = await getTasksCollection().insertOne(doc);
res.status(201).json({ _id: result.insertedId, ...doc });
} catch (err) {
if (err instanceof ValidationError) {
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 = await getTasksCollection()
.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 = await getTasksCollection().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 = await getTasksCollection().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 instanceof ValidationError) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
// DELETE /tasks/:id
router.delete('/:id', parseObjectId, async (req, res, next) => {
try {
const result = await getTasksCollection().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;
Ogni route nel codice sopra segue lo stesso schema: prende l’input dalla richiesta, lo passa ai helper che abbiamo creato prima, chiama MongoDB e poi restituisce una risposta. Con validazione e parsing degli ID già gestiti, qui il codice rimane piccolo e prevedibile. In pratica, funziona così:
- La route POST costruisce una nuova attività usando buildTaskDocument prima di inserirla
- Le route GET filtrano opzionalmente per categoria e restituiscono i risultati ordinati dal più recente
- La route PUT usa buildTaskUpdate, quindi gli aggiornamenti parziali sono sicuri e coerenti
- La route DELETE rimuove un’attività tramite il suo ObjectId già analizzato
Ci sono anche altri dettagli nel codice che vale la pena evidenziare:
- find() restituisce un cursore, non un array, quindi concateniamo .toArray() dopo l’ordinamento per ottenere effettivamente i risultati
- findOneAndUpdate con returnDocument: 'after' ti restituisce subito il documento aggiornato
- Restituiamo i corretti codici HTTP: 201 per create, 204 per delete e 400 o 404 dove appropriato
- Eventuali errori inattesi vengono passati a next(err), così possono essere gestiti in un solo punto invece che in ogni route
Passo 6: Collega tutto nel server
Ora tutti i pezzi sono al loro posto. Abbiamo validazione, route pulite e una connessione al database funzionante. Dobbiamo solo collegare tutto e avviare il server. Crea un file server.js. Questo è il punto di ingresso dell’app, dove tutto converge:
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);
}
const PORT = 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' });
});
async function start() {
try {
await connectDB();
const server = app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
const shutdown = () => {
console.log('\nShutting down gracefully...');
server.close(async () => {
await closeDB();
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 };
Questo file collega tutto in modo pulito. Controlla che le variabili d’ambiente siano impostate prima che parta qualsiasi cosa, applica una sicurezza di base con helmet e limita il JSON in ingresso così la tua app non accetta per errore payload enormi. Serve anche la cartella public/, così il tuo frontend può vivere nella stessa app senza un server separato.
Tutte le route sono montate sotto /tasks e tutto ciò che non corrisponde restituisce un pulito 404. Gli errori sono gestiti in un solo punto invece di essere ripetuti in ogni route, il che mantiene tutto coerente e più facile da mantenere. La connessione al database viene stabilita prima che il server inizi ad ascoltare, così non accetti richieste prima di essere pronto, e lo spegnimento è gestito in modo pulito, chiudendo correttamente le connessioni.
Ora avviamolo e verifichiamo che tutto funzioni eseguendo npm run dev. Se tutto è collegato correttamente, dovresti vedere qualcosa del genere:
MongoDB connected (db: taskmanager)
Server running on http://localhost:3000
A questo punto, tutto è collegato e in esecuzione! Ora abbiamo una API pienamente funzionante, quindi testiamola.
Passo 7: Testa gli endpoint dell’API
Prima di costruire un’interfaccia, vale la pena confermare che ogni endpoint funzioni da solo. Questo rende il debug molto più semplice, perché puoi capire subito se un problema viene dall’API o dal frontend. Useremo Postman (o qualsiasi client HTTP) ed eseguiremo alcune richieste in ordine. Ognuna si basa sui dati creati nel passaggio precedente.
Crea un’attività: invia una richiesta POST con titolo, descrizione e categoria. Se tutto funziona, l’API restituisce 201 Created con la nuova attività e il suo _id generato.
POST http://localhost:3000/tasks
Content-Type: application/json
{
"title": "Write GeeksForGeeks article",
"description": "First draft by Friday",
"category": "Work"
}
Creane altre: esegui la stessa richiesta POST con body diversi, così hai abbastanza dati per testare elenchi e filtri. Dopo questo, avrai un piccolo dataset da interrogare.
{ "title": "Go for a run", "category": "Personal" }
{ "title": "Read MongoDB docs", "category": "Study" }
{ "title": "Buy groceries" } // category defaults to "Other"
Elenca tutte le attività: questo restituisce tutte le attività, ordinate dalle più recenti.
GET http://localhost:3000/tasks
Filtra per categoria: questo restituisce solo le attività Lavoro. L’indice aggiunto prima mantiene questa query veloce quando i dati crescono.
GET http://localhost:3000/tasks?category=Work
Aggiorna un’attività: usa l’_id di una delle attività appena create (puoi copiarlo dalla risposta del POST o dall’endpoint di elenco). Questo restituisce l’attività aggiornata. Devi inviare solo i campi che vuoi cambiare, così gli aggiornamenti parziali restano semplici.
PUT http://localhost:3000/tasks/<paste-task-id-here>
Content-Type: application/json
{ "completed": true }
Elimina un’attività: usa l’_id dell’attività che vuoi eliminare. Questo restituisce 204 No Content. Se provi a recuperare di nuovo la stessa attività, otterrai un 404 con "task not found".
DELETE http://localhost:3000/tasks/<paste-task-id-here>
A questo punto, hai confermato che tutte le route si comportano come previsto. L’API fa il suo lavoro, quindi possiamo passare a costruire un’interfaccia sopra di essa.
Passo 8: Aggiungi un frontend per interagire con l’API
Postman è ottimo per verificare l’API, ma è bello vedere come funziona su un sito reale, quindi aggiungiamo del codice frontend alla nostra applicazione.
Invece di incollare qui lunghi file HTML e CSS, ho aggiunto il codice in questo repository GitHub. Vai lì e copia i contenuti di index.html, styles.css e favicon.svg nella tua cartella public/ alla radice del progetto. Salva le modifiche e vai su localhost:3000. Poi incolla il codice qui sotto nel file 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');
async function api(method, path, body) {
const res = await fetch(path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined
});
if (res.status === 204) return null;
const data = await res.json();
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
return data;
}
const fetchTasks = (category) =>
api('GET', '/tasks' + (category ? `?category=${encodeURIComponent(category)}` : ''));
const createTask = (body) => api('POST', '/tasks', body);
const updateTask = (id, body) => api('PUT', `/tasks/${id}`, body);
const deleteTask = (id) => api('DELETE', `/tasks/${id}`);
function updateCount(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`;
}
function renderTask(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;
}
function render(tasks) {
listEl.innerHTML = '';
emptyEl.classList.toggle('hidden', tasks.length > 0);
for (const task of tasks) {
listEl.appendChild(renderTask(task));
}
updateCount(tasks);
}
async function refresh() {
try {
const tasks = await fetchTasks(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 {
await createTask({
title,
description: descEl.value.trim(),
category: catEl.value
});
form.reset();
await refresh();
} catch (e) {
errEl.textContent = e.message;
}
});
filterEl.addEventListener('change', refresh);
refresh();
Il codice JavaScript è dove tutto si ricollega all’API che hai costruito. Ogni funzione corrisponde a una delle tue route, mantenendo il frontend facile da seguire. Tutte le richieste passano anche da un unico helper api(), quindi gestisci gli errori in un solo posto invece di ripetere la stessa logica ovunque.
Il filtro funziona passando una query string come ?category=Work, che si aggancia direttamente alla logica backend che hai già aggiunto. Dopo la creazione, l’aggiornamento o l’eliminazione di un’attività, l’app recupera di nuovo l’elenco più recente così l’interfaccia resta sincronizzata. Il contatore delle attività è un piccolo dettaglio, ma aiuta l’app a sembrare più reattiva mentre la usi.
Ora riavvia il server con npm run dev, poi apri http://localhost:3000 per provare il Task Manager. Aggiungi qualche attività, aggiornale, passa tra le categorie ed elimina anche qualcosa. A questo punto, frontend e backend sono completamente connessi e funzionano insieme end-to-end.
Riepilogo
Congratulazioni! Hai costruito da zero una API completa per la gestione delle attività usando Node.js, Express e MongoDB. Hai gestito la validazione, mantenuto le route snelle e collegato tutto in un sistema funzionante. Ancora più importante, sei rimasto vicino al modo in cui MongoDB funziona davvero, senza nasconderlo dietro astrazioni. Da qui puoi iniziare a far evolvere l’app per renderla pronta per la produzione.
Punti chiave
- Usare middleware e centralizzare la validazione mantiene il codice pulito e prevedibile.
- Il driver nativo di MongoDB ti dà controllo senza astrazioni inutili.
- Un’API ben strutturata è facile da estendere in un’app pronta per la produzione.
FAQs
Ho bisogno di Mongoose per creare un'API MongoDB?
No. Questo tutorial usa il driver nativo di MongoDB, che ti dà più controllo e mantiene tutto leggero. Puoi aggiungere Mongoose in seguito se vuoi astrazioni di schema.
Come valido i dati senza uno schema?
Puoi centralizzare la validazione in funzioni helper (come buildTaskDocument), assicurandoti che ogni route applichi le stesse regole prima di scrivere nel database.
Cosa succede se passo un ObjectId non valido?
Il middleware lo rifiuta subito con una risposta 400, evitando che MongoDB lanci errori fuorvianti.

