Curso
Uma lista de tarefas simples funciona bem no começo: você adiciona algumas tarefas e pode marcá-las como concluídas quando terminar. Mas, conforme o volume aumenta, ela fica difícil de usar. Uma solução é adicionar categorias, permitindo que cada tarefa pertença a grupos como Trabalho, Estudos, Pessoal etc., e você possa filtrar a lista pela categoria desejada.
Neste tutorial, você vai aprender a criar uma API REST que organiza tarefas por categoria usando Node.js e MongoDB. Ao final, você terá um servidor Node.js funcional com cinco endpoints: criar, listar, obter, atualizar e excluir, além de um filtro por categoria. Você também verá como aplicar validação de categorias usando o driver nativo do MongoDB, sem depender de um ODM como o Mongoose.
O que você vai aprender
- Como criar uma API REST com Node.js, Express e MongoDB, incluindo endpoints completos de CRUD
- Como validar entradas, aplicar categorias e lidar com valores de ObjectId de forma segura
- Como estruturar e testar sua API e depois conectá-la a um frontend
Você encontra o código completo deste tutorial no GitHub, caso prefira clonar o projeto e acompanhar a leitura.
Pré-requisitos
Antes de começar, você deve ter:
- Node.js 18+ instalado (npm é incluído por padrão)
- MongoDB rodando localmente ou um cluster gratuito no MongoDB Atlas
- Postman ou qualquer cliente HTTP
- Conhecimentos básicos de JavaScript e conceitos de REST
Passo 1: configure o projeto
Vamos construir nosso gerenciador de tarefas simples. Primeiro, crie uma nova pasta e inicialize um projeto Node executando os comandos abaixo no terminal:
mkdir task-manager-categories
cd task-manager-categories
npm init -y
Em seguida, vamos instalar quatro dependências de runtime necessárias: express para rotas, o driver oficial do MongoDB, dotenv para carregar variáveis de ambiente e, por fim, helmet para cabeçalhos de segurança.
npm install express mongodb dotenv helmet
Para desenvolvimento, instale o nodemon como dependência de dev usando o comando abaixo, para o servidor recarregar automaticamente quando houver mudanças nos arquivos:
npm install --save-dev nodemon
Depois, abra a pasta do projeto na sua IDE e atualize a seção scripts do package.json para incluir o seguinte. Assim, você pode iniciar o servidor com npm start ou rodá-lo no modo de desenvolvimento com npm run dev:
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
Aqui está a estrutura que vamos construir. Cada pasta tem um papel claro: db/ cuida da conexão com o banco, lib/ contém funções utilitárias, middleware/ guarda os middlewares do Express e routes/ define os handlers das requisições. A pasta public/ vai armazenar um pequeno frontend que adicionaremos depois. Mesmo em um projeto pequeno, essa estrutura facilita o entendimento e deixa claro onde cada parte do código deve ficar. Fique à vontade para criar a estrutura de pastas agora e ir preenchendo aos poucos, ou pular este passo e adicionar os arquivos conforme avançamos:
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: conecte ao MongoDB
Para armazenar e buscar tarefas, primeiro precisamos conectar nosso app ao MongoDB. Isso é feito com uma string de conexão, que informa ao driver do MongoDB como acessar seu banco. Em vez de codificá-la direto no código, é melhor mantê-la em um arquivo de ambiente. Isso mantém valores sensíveis fora do repositório e facilita alternar entre ambientes.
Crie um arquivo .env.example para documentar as variáveis necessárias e um arquivo .env com seus valores reais, assim:
# Copie este arquivo para `.env` e preencha com valores reais.
# --- MongoDB local ---
# Use isto se você estiver rodando o MongoDB localmente
MONGO_URI=mongodb://127.0.0.1:27017
# --- MongoDB Atlas ---
# Substitua <username>, <password> e <cluster-url> pelos seus valores
# Exemplo: 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
Com a string de conexão definida, podemos usá-la para configurar a conexão com o MongoDB. Crie o arquivo db/connect.js. É aqui que vamos inicializar o cliente do MongoDB e disponibilizá-lo para o resto do 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 };
Este arquivo configura um único cliente do MongoDB reutilizado pelo app inteiro. Você só deve criá-lo uma vez, já que o driver faz o pool de conexões automaticamente. Criar um novo cliente a cada requisição pode parecer ok no começo, mas causa gargalos de performance rapidamente.
Passo 3: defina a estrutura da tarefa e a validação
Agora que o app se conecta ao MongoDB, precisamos definir como é uma tarefa antes de salvar qualquer coisa.
Aqui, usar o driver nativo do MongoDB é um pouco diferente. Não há um arquivo de schema como no Mongoose. Em vez disso, o "schema" é o formato do objeto que você insere no banco. Pode parecer flexível demais no início, mas é útil porque você fica mais próximo do funcionamento real do MongoDB, sem camadas de abstração escondendo a lógica.
Ainda assim, queremos validação. Em vez de espalhar essa lógica pelas rotas, vamos centralizá-la em um único lugar para que tudo siga as mesmas regras. Crie o arquivo lib/taskDocument.js e adicione o código abaixo:
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
};
Este arquivo funciona como porteiro do que entra no seu banco. Toda criação ou atualização passa por aqui, então as regras são aplicadas em um único lugar.
Ele garante que as tarefas tenham o formato certo, mantém as categorias consistentes e evita surpresas quando você começar a consultar os dados. A ValidationError personalizada também separa entradas inválidas de problemas reais do servidor, permitindo respostas adequadas da API. Com isso pronto, o resto do app fica simples: cada rota foca no seu papel, sabendo que os dados já chegam validados. No próximo passo, vamos conectar as rotas e começar a salvar tarefas no MongoDB.
Passo 4: trate ObjectId e validação da requisição
Agora que sabemos como é uma tarefa, vamos definir como referenciá-la. Sempre que uma rota recebe um parâmetro :id, ele chega como string comum. Mas o MongoDB espera um ObjectId. Se a string for inválida, o driver lança um erro pouco amigável. Em vez de tratar isso em cada rota, vamos centralizar com um middleware para que todos os endpoints se comportem de forma consistente.
Para isso, crie o arquivo middleware/parseObjectId.js e adicione o seguinte:
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;
Este middleware roda antes do handler da rota. Ele valida o id, converte-o para ObjectId e anexa em req.taskId. Assim, quando a lógica da rota executar, você já está trabalhando com um ObjectId correto e entradas inválidas são rejeitadas cedo com uma resposta 400. No próximo passo, vamos plugá-lo nas rotas e ligar tudo.
Passo 5: crie as rotas de tarefas (CRUD + filtro)
A essa altura, o mais difícil já foi. Definimos o que é uma tarefa válida e como IDs são analisados e validados. Isso permite que os handlers de rota foquem em uma coisa: conversar com o banco.
Vamos criar as rotas da API no arquivo routes/tasks.js e adicionar o seguinte conteúdo:
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;
Cada rota segue o mesmo padrão: recebe a entrada, passa pelos helpers que criamos, chama o MongoDB e retorna a resposta. Com a validação e o parsing de ID já resolvidos, o código permanece enxuto e previsível. Na prática, funciona assim:
- A rota POST monta uma nova tarefa com buildTaskDocument antes de inserir
- As rotas GET podem filtrar por categoria e retornam resultados do mais novo para o mais antigo
- A rota PUT usa o buildTaskUpdate, então atualizações parciais são seguras e consistentes
- A rota DELETE remove a tarefa pelo ObjectId já validado
Alguns detalhes do código que valem destaque:
- find() retorna um cursor, não um array; por isso encadeamos .toArray() após o sort para obter os resultados
- findOneAndUpdate com returnDocument: 'after' já retorna o documento atualizado
- Retornamos códigos HTTP adequados: 201 para criação, 204 para exclusão e 400 ou 404 quando apropriado
- Erros inesperados são encaminhados para next(err), para tratamento centralizado, sem repetir lógica em cada rota
Passo 6: conecte tudo no servidor
Agora, temos todas as peças: validação, rotas limpas e conexão com o banco. Falta apenas ligar tudo e iniciar o servidor. Crie o arquivo server.js, ponto de entrada do app, onde tudo se encontra:
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 };
Este arquivo amarra tudo de forma limpa. Ele verifica as variáveis de ambiente antes de rodar, aplica segurança básica com helmet e limita o tamanho do JSON para evitar cargas acidentais muito grandes. Também serve a pasta public/, permitindo que o frontend rode no mesmo app sem precisar de outro servidor.
Todas as rotas ficam sob /tasks e qualquer caminho não encontrado retorna um 404 claro. Os erros são tratados em um único lugar, o que mantém consistência e facilita a manutenção. A conexão com o banco é estabelecida antes do servidor começar a ouvir, então você não aceita requisições antes de estar pronto, e o desligamento é gracioso, fechando conexões corretamente.
Agora vamos subir tudo e conferir se está funcionando com npm run dev. Se estiver tudo certo, você verá algo assim:
MongoDB connected (db: taskmanager)
Server running on http://localhost:3000
Pronto! Tudo ligado e rodando. Agora temos uma API funcional, então vamos testá-la.
Passo 7: teste os endpoints da API
Antes de construir uma UI, vale conferir se cada endpoint funciona sozinho. Isso facilita o debug, já que você identifica rapidamente se o problema vem da API ou do frontend. Vamos usar o Postman (ou qualquer cliente HTTP) e executar algumas requisições em ordem. Cada uma aproveita os dados criados na etapa anterior.
Crie uma tarefa: envie uma requisição POST com title, description e category. Se tudo der certo, a API retorna 201 Created com a nova tarefa e o _id gerado.
POST http://localhost:3000/tasks
Content-Type: application/json
{
"title": "Write GeeksForGeeks article",
"description": "First draft by Friday",
"category": "Work"
}
Crie mais algumas: repita a requisição POST com corpos diferentes para ter dados suficientes para testar listagem e filtros. Assim você terá um pequeno conjunto de dados para consultas.
{ "title": "Go for a run", "category": "Personal" }
{ "title": "Read MongoDB docs", "category": "Study" }
{ "title": "Buy groceries" } // category defaults to "Other"
Liste todas as tarefas: retorna todas as tarefas, do mais novo para o mais antigo.
GET http://localhost:3000/tasks
Filtre por categoria: retorna apenas as tarefas de Work. O índice criado antes mantém essa consulta rápida conforme os dados crescem.
GET http://localhost:3000/tasks?category=Work
Atualize uma tarefa: use o _id de uma das tarefas criadas (copie da resposta do POST ou do endpoint de listagem). A resposta traz a tarefa atualizada. Envie apenas os campos que deseja alterar para manter as atualizações parciais simples.
PUT http://localhost:3000/tasks/<paste-task-id-here>
Content-Type: application/json
{ "completed": true }
Exclua uma tarefa: use o _id da tarefa que deseja remover. A resposta é 204 No Content. Se tentar buscar a mesma tarefa depois, receberá 404 com "task not found".
DELETE http://localhost:3000/tasks/<paste-task-id-here>
Pronto, você confirmou que todas as rotas se comportam como esperado. A API está cumprindo seu papel, então vamos criar uma interface por cima dela.
Passo 8: adicione um frontend para interagir com a API
O Postman é ótimo para validar a API, mas é melhor ainda ver tudo funcionando em um site. Vamos adicionar um pouco de frontend ao nosso app.
Em vez de colar HTML e CSS extensos aqui, subi o código neste repositório do GitHub. Vá até lá e copie o conteúdo de index.html, styles.css e favicon.svg para a pasta public/ na raiz do seu projeto. Salve as mudanças e acesse localhost:3000. Depois cole o código abaixo no arquivo 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();
O JavaScript é onde tudo se reconecta à API que você construiu. Cada função mapeia para uma rota, deixando o frontend fácil de acompanhar. Todas as requisições passam por um único helper api(), então você trata erros em um só lugar, sem repetir lógica.
O filtro funciona passando uma query string como ?category=Work, diretamente integrado à lógica do backend. Após criar, atualizar ou excluir uma tarefa, o app busca a lista mais recente para manter a UI sincronizada. O contador de tarefas é um detalhe simples, mas ajuda o app a parecer mais vivo durante o uso.
Agora reinicie o servidor com npm run dev, depois abra http://localhost:3000 para testar o Task Manager. Adicione algumas tarefas, atualize, alterne entre categorias e exclua também. Agora o frontend e o backend estão totalmente conectados, de ponta a ponta.
Resumo
Parabéns! Você construiu do zero uma API completa de gerenciador de tarefas com Node.js, Express e MongoDB. Você aplicou validação, manteve as rotas enxutas e conectou tudo em um sistema funcional. Mais importante: ficou próximo do funcionamento real do MongoDB, sem esconder nada atrás de abstrações. A partir daqui, você pode evoluir o app para deixá-lo pronto para produção.
Principais aprendizados
- Usar middleware e centralizar a validação mantém o código limpo e previsível.
- O driver nativo do MongoDB dá controle sem abstrações desnecessárias.
- Uma API bem estruturada é fácil de evoluir para um app pronto para produção.
FAQs
Preciso do Mongoose para criar uma API com MongoDB?
Não. Este tutorial usa o driver nativo do MongoDB, que dá mais controle e mantém tudo leve. Você pode adicionar Mongoose depois, se quiser abstrações de schema.
Como valido dados sem um schema?
Você pode centralizar a validação em funções utilitárias (como buildTaskDocument), garantindo que toda rota aplique as mesmas regras antes de gravar no banco.
O que acontece se eu passar um ObjectId inválido?
O middleware rejeita cedo com uma resposta 400, evitando que o MongoDB dispare erros confusos.


