course
En enkel att‑göra‑lista funkar bra till en början: du lägger till några uppgifter och kan stänga dem när du är klar. Men när uppgifterna fortsätter att öka blir den jobbig att använda. En sak som kan lösa detta är att lägga till kategorisering, vilket låter varje uppgift tillhöra kategorier som Work, Study, Personal osv., och du kan filtrera listan till den kategori som passar.
I den här handledningen lär du dig hur du bygger ett REST-API som låter dig organisera uppgifter efter kategori med Node.js och MongoDB. I slutet har du en fungerande Node.js‑server med fem endpoints: create, list, get, update och delete, tillsammans med ett kategorifilter. Du får också se hur du upprätthåller kategorivalidering med den inbyggda MongoDB‑drivrutinen, utan att förlita dig på en ODM som Mongoose.
Det här kommer du att lära dig
- Hur du bygger ett REST-API med Node.js, Express och MongoDB, inklusive kompletta CRUD‑endpoints
- Hur du validerar indata, upprätthåller kategorier och hanterar ObjectId‑värden på ett säkert och effektivt sätt
- Hur du strukturerar och testar ditt API och sedan kopplar det till ett frontend
Du hittar den kompletta koden för den här handledningen på GitHub om du föredrar att klona den och läsa vidare samtidigt som du går igenom stegen.
Förkunskaper
Innan du börjar bör du ha:
- Node.js 18+ installerat (npm ingår som standard)
- MongoDB som körs lokalt, eller ett kostnadsfritt MongoDB Atlas‑kluster
- Postman eller valfri HTTP‑klient
- Grundläggande kunskaper i JavaScript och REST‑koncept
Steg 1: Ställ in projektet
Låt oss börja bygga vår enkla uppgiftshanterare. Det första vi gör är att skapa en ny mapp och initiera ett Node‑projekt genom att köra kommandona nedan i terminalen:
mkdir task-manager-categories
cd task-manager-categories
npm init -y
Därefter installerar vi de fyra körberoenden som vår app behöver: express för routing, den officiella MongoDB‑drivrutinen, dotenv för att ladda miljövariabler, och slutligen helmet för säkerhetsheaders.
npm install express mongodb dotenv helmet
För utveckling, installera nodemon som ett dev‑beroende med kommandot nedan så att servern laddas om automatiskt när filer ändras:
npm install --save-dev nodemon
Öppna sedan projektmappen i din IDE och uppdatera avsnittet scripts i package.json för att inkludera följande. Detta låter dig starta servern med npm start eller köra den i utvecklingsläge med npm run dev:
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
Här är strukturen vi bygger allt eftersom. Varje mapp har en tydlig roll: db/ hanterar databaskopplingen, lib/ innehåller hjälpfunktioner, middleware/ är för Express‑middleware och routes/ definierar request‑handlers. Mappen public/ kommer att hålla ett litet frontend som vi lägger till senare. Även i ett litet projekt håller den här strukturen saker lättöverskådliga och gör det tydligt var ny kod ska hamna. Känn dig fri att skapa mapplayouten nu och fylla på allt eftersom, eller hoppa över det och lägga till varje fil när vi går igenom stegen:
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
Steg 2: Anslut till MongoDB
För att lagra och hämta uppgifter behöver vi först ansluta vår app till MongoDB. Detta görs med en anslutningssträng som talar om för MongoDB‑drivrutinen hur den ska ansluta till din databas. Istället för att hårdkoda den direkt i koden är det bättre att lägga den i en miljöfil. Det håller känsliga värden utanför kodbasen och gör det enklare att växla mellan miljöer.
Skapa en fil .env.example för att dokumentera nödvändiga variabler, och en fil .env för dina faktiska värden, så här:
# 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
Nu när vi har en anslutningssträng på plats kan vi använda den för att ställa in vår MongoDB‑anslutning. Skapa en filen db/connect.js. Här initierar vi MongoDB‑klienten och gör den tillgänglig för resten av appen:
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 };
Den här filen sätter upp en enda MongoDB‑klient som resten av appen kan återanvända. Du vill bara skapa denna en gång, eftersom drivrutinen redan hanterar connection pooling under huven. Att skapa en ny klient för varje request kan verka okej först, men kommer ganska snabbt att orsaka prestandaproblem.
Steg 3: Definiera uppgiftsstruktur och validering
Nu kan appen ansluta till MongoDB. Nu behöver vi definiera hur en uppgift faktiskt ser ut innan vi börjar spara något.
Det är här användningen av den inbyggda MongoDB‑drivrutinen börjar kännas lite annorlunda. Det finns ingen schemafil som med Mongoose. I stället är ”schemat” helt enkelt formen på objektet du skriver in i databasen. Det kan kännas lite löst i början, men det är faktiskt hjälpsamt eftersom du håller dig nära vad MongoDB verkligen gör, och inget döljs bakom abstraktioner.
Vi vill ändå ha validering. Så i stället för att sprida logiken över olika rutter håller vi den på ett ställe så att allt följer samma regler. Skapa en fil som heter lib/taskDocument.js och lägg till koden nedan:
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
};
Den här filen fungerar som grindvakt för allt som går in i din databas. Varje create‑ eller update‑begäran passerar härigenom, så du har ett ställe som upprätthåller reglerna.
Den säkerställer att uppgifter alltid har rätt form, håller kategorier konsekventa och undviker överraskningar senare när du börjar fråga på din data. Den anpassade ValidationError ger dig också ett rent sätt att skilja dålig indata från verkliga serverproblem, så att ditt API kan svara lämpligt. Med detta på plats kan resten av appen förbli enkel. Varje rutt kan fokusera på sitt jobb, i trygg förvissning om att datan den tar emot redan är giltig. Nästa steg är att koppla in rutterna och börja spara uppgifter i MongoDB.
Steg 4: Hantera ObjectId och begärandevalidering
Nu när vi vet hur en uppgift ser ut, låt oss hantera hur vi refererar till en. När en rutt inkluderar en :id‑parameter kommer den in som en vanlig sträng. Men MongoDB förväntar sig ett ObjectId. Om strängen är felaktig kastar drivrutinen ett ganska otydligt fel. I stället för att hantera detta i varje rutt centraliserar vi det med middleware så att varje endpoint beter sig på samma sätt.
För att göra detta, skapa en fil som heter middleware/parseObjectId.js och lägg till följande:
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;
Den här middleware:n körs innan din route‑handler. Den kontrollerar att id är giltigt, konverterar det till ett ObjectId och fäster det på req.taskId. På så sätt, när din ruttlogik körs, arbetar du alltid med ett korrekt ObjectId, och dåliga indata avvisas tidigt med ett tydligt svar 400. Nästa steg är att koppla in detta i våra rutter och börja koppla ihop allt.
Steg 5: Bygg uppgiftsrutterna (CRUD + filtrering)
Vid det här laget är det mesta av grovjobbet redan gjort. Vi har definierat hur en giltig uppgift ser ut och hanterat hur ID:n tolkas och valideras. Det betyder att våra route‑handlers kan fokusera på en sak: att prata med databasen.
Låt oss bygga de faktiska API‑rutterna genom att skapa en fil som heter routes/tasks.js och lägga till följande i den:
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;
Varje rutt i koden ovan följer samma mönster: ta indata från begäran, skicka dem genom hjälpfunktionerna vi byggde tidigare, anropa MongoDB och returnera sedan ett svar. Eftersom validering och ID‑tolkning redan är hanterade håller sig koden här liten och förutsägbar. I praktiken ser det ut så här:
- Routen POST bygger en ny uppgift med buildTaskDocument innan den infogas
- Ruterna GET filtrerar valfritt på kategori och returnerar resultat sorterade nyast först
- Routen PUT använder buildTaskUpdate, så partiella uppdateringar är säkra och konsekventa
- Routen DELETE tar bort en uppgift med dess redan tolkade ObjectId
Det finns några andra detaljer i den här koden som också är värda att nämna:
- find() returnerar en cursor, inte en array, så vi kedjar .toArray() efter sorteringen för att faktiskt få resultaten
- findOneAndUpdate med returnDocument: 'after' ger dig det uppdaterade dokumentet direkt
- Vi returnerar korrekta HTTP‑statuskoder: 201 för create, 204 för delete, och 400 eller 404 där det är lämpligt
- Eventuella oväntade fel skickas till next(err) så att de kan hanteras på ett ställe i stället för i varje rutt
Steg 6: Koppla ihop allt i servern
Nu är alla delar på plats. Vi har validering, rena rutter och en fungerande databaskoppling. Nu behöver vi bara koppla ihop allt och faktiskt starta servern. Skapa en fil server.js. Detta är appens ingångspunkt, där allt kommer samman:
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 };
Den här filen knyter ihop allt på ett prydligt sätt. Den kontrollerar att dina miljövariabler är satta innan något körs, tillämpar grundläggande säkerhet med helmet och begränsar inkommande JSON så att din app inte av misstag accepterar enorma payloads. Den tjänar också din mapp public/, så ditt frontend kan leva i samma app utan att behöva en separat server.
Alla dina rutter monteras under /tasks, och allt som inte matchar returnerar en ren 404. Fel hanteras på ett ställe i stället för att upprepas i varje rutt, vilket håller saker konsekventa och lättare att underhålla. Databaskopplingen upprättas innan servern börjar lyssna, så du accepterar aldrig förfrågningar innan du är redo, och nedstängning hanteras graciöst så att anslutningar stängs ordentligt.
Starta nu upp och säkerställ att allt fungerar genom att köra npm run dev. Om allt är rätt kopplat bör du se något i stil med detta:
MongoDB connected (db: taskmanager)
Server running on http://localhost:3000
Nu är allt ihopkopplat och igång! Vi har nu ett fullt fungerande API, så låt oss testa det.
Steg 7: Testa API‑endpoints
Innan du bygger ett UI är det värt att bekräfta att varje endpoint fungerar för sig. Det gör felsökning mycket enklare, eftersom du snabbt kan avgöra om ett problem kommer från API:et eller frontend. Vi använder Postman (eller valfri HTTP‑klient) och kör några begäranden i ordning. Var och en bygger på datan som skapats i föregående steg.
Skapa en uppgift: Skicka en POST‑begäran med en title, description och category. Om allt funkar returnerar API:et 201 Created med den nya uppgiften och dess genererade _id.
POST http://localhost:3000/tasks
Content-Type: application/json
{
"title": "Write GeeksForGeeks article",
"description": "First draft by Friday",
"category": "Work"
}
Skapa några till: Kör samma POST‑begäran med olika body:s så att du har tillräckligt med data för att testa listning och filtrering. Efter detta har du en liten dataset att fråga på.
{ "title": "Go for a run", "category": "Personal" }
{ "title": "Read MongoDB docs", "category": "Study" }
{ "title": "Buy groceries" } // category defaults to "Other"
Lista alla uppgifter: Detta returnerar alla uppgifter, sorterade nyast först.
GET http://localhost:3000/tasks
Filtrera efter kategori: Detta returnerar endast Work‑uppgifter. Indexet du lade till tidigare är det som håller den här frågan snabb när datan växer.
GET http://localhost:3000/tasks?category=Work
Uppdatera en uppgift: Använd _id från en av uppgifterna du just skapade (du kan kopiera den från svaret på POST eller list‑endpointen). Detta returnerar den uppdaterade uppgiften. Du behöver bara skicka de fält du vill ändra, så partiella uppdateringar förblir enkla.
PUT http://localhost:3000/tasks/<paste-task-id-here>
Content-Type: application/json
{ "completed": true }
Ta bort en uppgift: Använd _id från uppgiften du vill ta bort. Detta returnerar 204 No Content. Om du försöker hämta samma uppgift igen får du en 404 med "task not found".
DELETE http://localhost:3000/tasks/<paste-task-id-here>
Nu har du bekräftat att alla rutter beter sig som förväntat. API:et gör sitt jobb, så nu kan vi gå vidare till att bygga ett UI ovanpå det.
Steg 8: Lägg till ett frontend för att interagera med API:et
Postman är bra för att verifiera API:et, men det vore trevligt att se hur det funkar på en faktisk webbplats, så låt oss lägga till lite frontend‑kod i vår applikation.
Istället för att klistra in stora HTML‑ och CSS‑filer här har jag lagt koden i detta GitHub‑repo. Gå dit och kopiera innehållet i index.html, styles.css och favicon.svg till din mapp public/ i projektroten. Spara ändringarna och gå sedan till localhost:3000. Klistra sedan in koden nedan i filen 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();
JavaScript‑koden är där allt kopplas tillbaka till API:et du byggde tidigare. Varje funktion mappar till en av dina rutter, vilket gör frontend lätt att följa. Alla begäranden går också genom en enda hjälpfunktion api(), så du hanterar fel på ett ställe i stället för att upprepa samma logik överallt.
Filtrering fungerar genom att skicka en query‑sträng som ?category=Work, vilket knyter an direkt till backend‑logiken du redan lagt till. Efter att ha skapat, uppdaterat eller tagit bort en uppgift hämtar appen den senaste listan igen så att UI:t förblir i synk. Uppgiftsräknaren är en liten detalj, men den får appen att kännas mer levande när du använder den.
Starta nu om servern med npm run dev, öppna sedan http://localhost:3000 för att prova Task Manager. Lägg till några uppgifter, uppdatera dem, byt mellan kategorier och ta bort några också. Nu är frontend och backend helt sammanlänkade och fungerar från ände till ände.
Sammanfattning
Grattis! Du har byggt ett komplett API för uppgiftshantering från grunden med Node.js, Express och MongoDB. Du hanterade validering, höll dina rutter små och kopplade ihop allt till ett fungerande system. Ännu viktigare: du höll dig nära hur MongoDB faktiskt fungerar i stället för att gömma det bakom abstraktioner. Härifrån kan du börja utveckla appen vidare för att göra den produktionsredo.
Viktiga lärdomar
- Att använda middleware och centralisera validering håller din kod ren och förutsägbar.
- Den inbyggda MongoDB‑drivrutinen ger dig kontroll utan onödig abstraktion.
- Ett välstrukturerat API är lätt att bygga ut till en produktionsredo app.
FAQs
Behöver jag Mongoose för att bygga ett MongoDB‑API?
Nej. Den här handledningen använder den inbyggda MongoDB‑drivrutinen, som ger dig mer kontroll och håller saker lättviktiga. Du kan lägga till Mongoose senare om du vill ha schemaabstraktioner.
Hur validerar jag data utan ett schema?
Du kan centralisera validering i hjälpfunktioner (som buildTaskDocument), vilket säkerställer att varje rutt upprätthåller samma regler innan du skriver till databasen.
Vad händer om jag skickar ett ogiltigt ObjectId?
Middleware:n avvisar det tidigt med ett svar 400 och förhindrar att MongoDB kastar förvirrande fel.