メインコンテンツへスキップ

Node.js と MongoDB でカテゴリ付きのシンプルなタスク管理アプリを構築する

Node.js と MongoDB を使ってカテゴリ別にタスクを整理しましょう。作成・一覧・取得・更新・削除とカテゴリフィルターを備えた REST API を構築します。
更新 2026年9月10日  · 10 分 読む

AIで探索

ChatGPTClaudePerplexity

シンプルな ToDo リストは最初は便利です。タスクを追加し、終わったら閉じるだけで済みます。しかしタスクが増えるにつれて、使いづらくなっていきます。これを改善する一つの方法がカテゴリ分けです。タスクを Work、Study、Personal などのカテゴリに属させ、一覧をそのカテゴリでフィルタリングできるようにします。

このチュートリアルでは、Node.js と MongoDB を使って、タスクをカテゴリ別に整理できる REST API の作り方を学びます。最後には、作成・一覧・取得・更新・削除の5つのエンドポイントとカテゴリフィルターを備えた、動作する Node.js サーバーが完成します。さらに、Mongoose のような ODM に頼らず、ネイティブの MongoDB ドライバーでカテゴリのバリデーションを強制する方法も紹介します。

学べること

  • Node.js、Express、MongoDB を使った REST API の構築方法(CRUD エンドポイント一式を含む)
  • 入力の検証、カテゴリの強制、ObjectId の安全な取り扱い方
  • API の設計とテスト方法、その後フロントエンドに接続する方法

チュートリアルの完成版コードは GitHub で公開しています。クローンして読み進めても構いません。

前提条件

始める前に、以下を用意してください。

  • Node.js 18+ をインストール済み(npm は同梱)
  • ローカルで稼働中の MongoDB、または無料の MongoDB Atlas クラスター
  • Postman などの HTTP クライアント
  • JavaScript と REST の基礎知識

ステップ 1: プロジェクトのセットアップ

ここからシンプルなタスク管理アプリを作っていきます。まずは新しいフォルダーを作成し、以下のコマンドで Node プロジェクトを初期化します。

mkdir task-manager-categories
cd task-manager-categories
npm init -y

次に、アプリの実行に必要なランタイム依存関係を 4 つインストールします。ルーティングの express、公式の MongoDB ドライバー、環境変数読み込み用の dotenv、そして最後にセキュリティヘッダーの helmet です。

npm install express mongodb dotenv helmet

開発用には nodemon を dev 依存関係としてインストールします。これでファイル変更時にサーバーが自動再起動します。

npm install --save-dev nodemon

次に、IDE でプロジェクトフォルダーを開き、package.jsonscripts セクションを次のように更新します。これで npm start でサーバーを起動したり、npm run dev で開発モード実行ができます。

"scripts": {
  "start": "node server.js",
  "dev": "nodemon server.js"
}

これから作る構成は次のとおりです。各フォルダーの役割は明確です。 db/ はデータベース接続、lib/ はヘルパー関数、middleware/ は Express のミドルウェア、routes/ はリクエストハンドラーを定義します。さらに public/ には後で追加する小さなフロントエンドを置きます。小規模なプロジェクトでも、この構成にしておくと見通しが良くなり、新しいコードの置き場所も明確になります。先にフォルダーだけ作って進めても、各ステップでファイルを追加していっても構いません。

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

ステップ 2: MongoDB に接続する

タスクを保存・取得するには、まずアプリを MongoDB に接続する必要があります。これは接続文字列を使って行います。接続文字列は、MongoDB ドライバーにデータベースへの接続方法を伝えるものです。コードにベタ書きするのではなく、環境ファイルで管理するのがおすすめです。機密値をコードベースから分離でき、環境の切り替えも容易になります。

必要な変数を記載するための .env.example と、実際の値を入れる .env ファイルを次のように作成します。

# 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

接続文字列の準備ができたので、MongoDB への接続を設定します。db/connect.js ファイルを作成してください。ここで MongoDB クライアントを初期化し、アプリ全体から利用できるようにします。

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 };

このファイルは、アプリ全体で再利用する単一の MongoDB クライアントを設定します。クライアントは 1 度だけ作るのが理想です。ドライバーは内部で接続プールを扱っているためです。リクエストごとに新しいクライアントを作るのは一見問題なさそうですが、すぐにパフォーマンスの問題を引き起こします。

ステップ 3: タスクの構造とバリデーションを定義する

これでアプリは MongoDB に接続できるようになりました。次は、保存を始める前に、タスクが実際にどのような形なのかを定義します。

ネイティブの MongoDB ドライバーを使うと、ここが少し違って感じられます。Mongoose のようなスキーマファイルはありません。代わりに「スキーマ」は、データベースに挿入するオブジェクトの形そのものです。最初はゆるく感じるかもしれませんが、実際には MongoDB の実態に近く、抽象化の裏に隠れるものがないので有益です。

とはいえ、バリデーションは必要です。ロジックを各ルートに散らすのではなく、1 箇所にまとめて、すべてが同じルールに従うようにします。 lib/taskDocument.js というファイルを作成し、次のコードを追加してください。

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
};

このファイルは、データベースに入るすべてのものに対するゲートキーパーの役割を果たします。作成や更新のリクエストは必ずここを通るため、ルールを 1 箇所で強制できます。

タスクの形を常に正しく保ち、カテゴリを一貫させ、データのクエリを始めたときに驚かないようにします。カスタムの ValidationError により、不正な入力と本当のサーバー問題をきれいに切り分けられ、API が適切に応答できます。これが整えば、アプリの残りはシンプルに保てます。各ルートは自身の役割に集中でき、受け取るデータがすでに妥当であることを前提にできます。次はルートを配線し、MongoDB にタスクを保存していきます。

ステップ 4: ObjectId とリクエストの検証を扱う

タスクの形が分かったので、参照方法を扱いましょう。ルートに :id パラメータが含まれると、それは単なる文字列として渡されます。しかし MongoDB は ObjectId を期待します。その文字列の形式が不正だと、ドライバーはあまり役に立たないエラーを投げます。各ルートで対処するのではなく、ミドルウェアで一元化し、すべてのエンドポイントが同じ挙動をするようにします。

そのために、middleware/parseObjectId.js を作成し、次を追加します。

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;

このミドルウェアはルートハンドラーの前に実行されます。渡された id が妥当かを確認し、ObjectId に変換して req.taskId に付与します。こうしてルートのロジックが実行される時点では常に正しい ObjectId を扱えるようになり、不正な入力は早期に明確な 400 応答で拒否されます。次はこれをルートに組み込み、全体をつなげていきます。

ステップ 5: タスクのルートを作る(CRUD + フィルタリング)

ここまでで主要な作業は済んでいます。妥当なタスクの形を定義し、ID のパースと検証も行えるようになりました。つまり、ルートハンドラーはデータベースとのやり取りに専念できます。

実際の API ルートを作成しましょう。 routes/tasks.js を作成し、次のコードを追加します。

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;

上記の各ルートは、リクエストから入力を受け取り、先ほど作ったヘルパーに通し、MongoDB を呼び出し、レスポンスを返すという同じ流れに従います。バリデーションと ID パースがすでに処理されているため、ここでのコードは小さく予測可能なままです。実際の動きは次のとおりです。

  • POST ルートは挿入前に buildTaskDocument で新しいタスクを構築する
  • GET ルートはオプションでカテゴリでフィルタし、新しい順に並べて返す
  • PUT ルートは buildTaskUpdate を使うため、部分的な更新でも安全で一貫性がある
  • DELETE ルートは、すでにパース済みの ObjectId でタスクを削除する

このコードには、他にも押さえておくべきポイントがあります。

  • find() は配列ではなくカーソルを返すため、並べ替えの後に結果を得るには .toArray() をチェーンする
  • findOneAndUpdatereturnDocument: 'after' を指定すると、更新後のドキュメントを即座に取得できる
  • HTTP ステータスコードを適切に返す:作成は 201、削除は 204、状況に応じて 400404 を返す
  • 想定外のエラーは next(err) に渡して、各ルート内ではなく一箇所で処理する

ステップ 6: サーバーで全体をつなぐ

ここまでで必要な部品は揃いました。バリデーション、シンプルなルート、動作するデータベース接続があります。あとは全体をつないでサーバーを起動するだけです。アプリのエントリーポイントとなる server.js を作成します。ここにすべてが集約されます。

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 };

このファイルは、全体をクリーンに結び付けます。実行前に環境変数が設定されているかをチェックし、helmet で基本的なセキュリティを適用し、誤って巨大なペイロードを受け入れないよう JSON のサイズを制限します。また public/ フォルダーを配信するため、フロントエンドを同一アプリ内で動かせ、別サーバーは不要です。

すべてのルートは /tasks にマウントされ、それ以外はクリーンな 404 を返します。エラーは各ルート内ではなく一箇所で処理するため、一貫性があり保守もしやすくなります。サーバーがリッスンを始める前にデータベース接続が確立されるため、準備が整う前にリクエストを受け付けることはありません。シャットダウンもグレースフルに行われ、接続が適切に閉じられます。

では npm run dev を実行して、すべてが動いているか確認しましょう。正しく配線できていれば、次のような表示が出ます。

MongoDB connected (db: taskmanager)
Server running on http://localhost:3000

これで配線も完了し、動作しています。API は完成したので、テストしていきましょう。

ステップ 7: API エンドポイントをテストする

UI を作る前に、各エンドポイントが単体で動くことを確認する価値があります。問題が API 側かフロントエンド側かをすぐに切り分けられるため、デバッグが楽になります。ここでは Postman(または任意の HTTP クライアント)を使い、いくつかのリクエストを順に実行します。各ステップは前のステップで作成したデータを前提とします。

タスクを作成: タイトル、説明、カテゴリを指定して POST リクエストを送ります。成功すれば、API は新しいタスクと生成された _id を返し、ステータスは 201 Created です。

POST http://localhost:3000/tasks
Content-Type: application/json

{
  "title": "Write GeeksForGeeks article",
  "description": "First draft by Friday",
  "category": "Work"
}

さらにいくつか作成: 同じ POST リクエストを異なるボディで実行して、一覧やフィルタリングのテストに十分なデータを作ります。これで小さなデータセットができ、クエリできます。

{ "title": "Go for a run", "category": "Personal" }
{ "title": "Read MongoDB docs", "category": "Study" }
{ "title": "Buy groceries" } // category defaults to "Other"

タスクを一覧表示: すべてのタスクを新しい順で返します。

GET http://localhost:3000/tasks

カテゴリでフィルタ: Work のタスクだけを返します。先ほど追加したインデックスにより、データが増えてもこのクエリは高速です。

GET http://localhost:3000/tasks?category=Work

タスクを更新: 直前に作成したタスクの _id を使います(POST の応答や一覧エンドポイントからコピーできます)。更新後のタスクが返ります。変更したいフィールドだけ送ればよく、部分更新がシンプルです。

PUT http://localhost:3000/tasks/<paste-task-id-here>
Content-Type: application/json

{ "completed": true }

タスクを削除: 削除したいタスクの _id を使います。応答は 204 No Content です。同じタスクを再取得しようとすると、404"task not found" が返ります。

DELETE http://localhost:3000/tasks/<paste-task-id-here>

これで、すべてのルートが想定どおりに動作することを確認できました。API が役目を果たしているので、その上に UI を構築していきます。

ステップ 8: API とやり取りするフロントエンドを追加する

Postman は API 検証には便利ですが、実際のウェブサイトでの動作も見てみたいところです。そこで、アプリにフロントエンドのコードを追加します。

ここで大きな HTML や CSS を貼り付ける代わりに、GitHub リポジトリにコードを用意しました。そちらにアクセスして、index.htmlstyles.cssfavicon.svg の内容をプロジェクトルートの public/ フォルダーにコピーしてください。保存したら localhost:3000 にアクセスします。続いて、下のコードを 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 は、すべてを先ほど作った API に接続します。各関数はルート 1 つに対応するため、フロントエンドの見通しが良くなります。リクエストもすべて共通の api() ヘルパーを通すので、エラー処理をあちこちに重複させずに済みます。

フィルタリングは ?category=Work のようなクエリ文字列を渡す仕組みで、バックエンドに実装済みのロジックにそのままつながります。タスクの作成・更新・削除後は最新の一覧を再取得し、UI の同期を保ちます。タスクカウンターは小さな工夫ですが、使っていてアプリがより生き生きと感じられます。

ここでサーバーを npm run dev で再起動し、http://localhost:3000 を開いて Task Manager を試してください。いくつかタスクを追加・更新し、カテゴリを切り替え、削除も行ってみましょう。これでフロントエンドとバックエンドが端から端までつながり、連携して動作します。

まとめ

おめでとうございます。Node.js、Express、MongoDB を使って、ゼロからタスク管理 API を完成させました。バリデーションを実装し、ルートをコンパクトに保ち、全体を連携させました。さらに、抽象化の背後に隠さず、MongoDB の実態に近い形で扱ってきました。ここからは、本番運用に向けてアプリを発展させていけます。

重要なポイント

  • ミドルウェアとバリデーションの一元化で、コードはクリーンかつ予測可能になる。
  • ネイティブの MongoDB ドライバーは、不要な抽象化なしにコントロールを与えてくれる。
  • よく設計された API は、本番向けアプリへの拡張が容易である。

FAQs

MongoDB API を作るのに Mongoose は必要ですか?

いいえ。このチュートリアルはネイティブの MongoDB ドライバーを使用します。これによりコントロール性が高く、軽量に保てます。スキーマの抽象化が必要になったら、後から Mongoose を追加できます。

スキーマなしでデータをどう検証しますか?

buildTaskDocument のような)ヘルパー関数にバリデーションを一元化することで、データベースに書き込む前にすべてのルートで同じルールを適用できます。

不正な ObjectId を渡したらどうなりますか?

ミドルウェアが早期に 400 で拒否し、MongoDB が分かりにくいエラーを投げるのを防ぎます。

トピック
MongoDB

おすすめの DataCamp 講座

Courses

Pythonで学ぶMongoDB入門

3時間
24.2K
MongoDBで柔軟な構造のデータを操作・分析する方法を学びます。
詳細を見るRight Arrow
コースを開始
もっと見るRight Arrow