Series: Pure Node.js — Zero Dependencies

Post #5: JSON Files as a Database

📅 March 2026 ⏱ 9 min read 🏷 Node.js, fs, CRUD, JSON

📚 Pure Node.js Series — Zero Dependencies

#1: Build an HTTP Server From Scratch #2: Manual Routing — No Express Needed #3: Serving Static Files (HTML, CSS, JS) #4: Handling Forms & POST Requests → #5: JSON Files as a Database (you are here) #6: HTML Templating with String Interpolation #7: Vanilla JS Charts & Dynamic UI #8: WebSockets From Scratch #9: Chat Rooms & Broadcast Messages #10: Sessions & Cookies Without Packages #11: File Uploads & Image Serving #12: Deploy Pure Node.js to a VPS

Why JSON Files?

For small apps and prototypes, a JSON file is a perfectly valid database. It's human-readable, no setup needed, and Node.js can read and write it natively. In this post we build a complete CRUD layer for our Todo app — no SQLite, no MongoDB, just fs.promises and plain JSON.

Setting Up the Data Layer

// db/todos.js
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');

const DB_PATH = path.join(__dirname, '../data/todos.json');

// Ensure the data directory and file exist
async function ensureDb() {
  try {
    await fs.access(DB_PATH);
  } catch {
    await fs.mkdir(path.dirname(DB_PATH), { recursive: true });
    await fs.writeFile(DB_PATH, JSON.stringify([]));
  }
}

// Read all todos
async function readAll() {
  await ensureDb();
  const raw = await fs.readFile(DB_PATH, 'utf8');
  return JSON.parse(raw);
}

// Write atomically — write to temp file first, then rename
async function writeAll(todos) {
  const tmp = DB_PATH + '.tmp';
  await fs.writeFile(tmp, JSON.stringify(todos, null, 2));
  await fs.rename(tmp, DB_PATH);
}

module.exports = { readAll, writeAll };
Why write to a temp file first? If the process crashes mid-write, you'd corrupt your database file. Writing to a .tmp file and then renaming is an atomic operation on most filesystems — the rename either fully happens or it doesn't.

UUID Without the uuid Package

Node.js v14.17+ ships with crypto.randomUUID() — a standards-compliant UUID v4 generator. No package needed:

const { randomUUID } = require('crypto');

const id = randomUUID();
// → 'b9d1a654-8f3e-4c12-a7f0-3d9e12345678'

Full CRUD Operations

// db/todos.js (continued)

// CREATE
async function create(title) {
  const todos = await readAll();
  const todo = {
    id: randomUUID(),
    title: title.trim(),
    done: false,
    createdAt: new Date().toISOString()
  };
  todos.push(todo);
  await writeAll(todos);
  return todo;
}

// READ ONE
async function findById(id) {
  const todos = await readAll();
  return todos.find(t => t.id === id) || null;
}

// UPDATE
async function update(id, changes) {
  const todos = await readAll();
  const index = todos.findIndex(t => t.id === id);
  if (index === -1) return null;

  todos[index] = { ...todos[index], ...changes, updatedAt: new Date().toISOString() };
  await writeAll(todos);
  return todos[index];
}

// DELETE
async function remove(id) {
  const todos = await readAll();
  const filtered = todos.filter(t => t.id !== id);
  if (filtered.length === todos.length) return false; // not found
  await writeAll(filtered);
  return true;
}

// TOGGLE done status
async function toggle(id) {
  const todo = await findById(id);
  if (!todo) return null;
  return update(id, { done: !todo.done });
}

module.exports = { readAll, create, findById, update, remove, toggle };

Wiring CRUD Into Route Handlers

// handlers/todos.js
const db = require('../db/todos');

async function handleListTodos(req, res) {
  const todos = await db.readAll();
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ todos, count: todos.length }));
}

async function handleCreateTodo(req, res) {
  // body parsing from Post #4
  const body = await readBody(req);
  const params = new URLSearchParams(body);
  const title = params.get('title');

  if (!title?.trim()) {
    res.writeHead(422, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: 'Title required' }));
    return;
  }

  await db.create(title);
  res.writeHead(303, { Location: '/todos' });
  res.end();
}

async function handleToggleTodo(req, res, params) {
  const todo = await db.toggle(params.id);
  if (!todo) {
    res.writeHead(404, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: 'Not found' }));
    return;
  }
  res.writeHead(303, { Location: '/todos' });
  res.end();
}

async function handleDeleteTodo(req, res, params) {
  const deleted = await db.remove(params.id);
  if (!deleted) {
    res.writeHead(404, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: 'Not found' }));
    return;
  }
  res.writeHead(303, { Location: '/todos' });
  res.end();
}

The Data File

After adding a few todos, data/todos.json looks like this:

[
  {
    "id": "b9d1a654-8f3e-4c12-a7f0-3d9e12345678",
    "title": "Buy milk",
    "done": false,
    "createdAt": "2026-03-20T10:00:00.000Z"
  },
  {
    "id": "c2e4f789-1a2b-3c4d-5e6f-7a8b9c0d1e2f",
    "title": "Learn pure Node.js",
    "done": true,
    "createdAt": "2026-03-20T09:00:00.000Z",
    "updatedAt": "2026-03-20T10:05:00.000Z"
  }
]
When NOT to use this approach: JSON file databases are great for prototypes and single-instance apps. They don't support concurrent writes safely, have no query language, and don't scale past a few thousand records. For production use SQLite (built into Node.js v22+ via node:sqlite) or a proper database.
Key takeaways:
  • fs.promises.readFile/writeFile — async file I/O, no callbacks needed
  • Write to .tmp then rename for atomic writes
  • crypto.randomUUID() — UUID v4 built into Node.js, no package needed
  • Wrap all file ops in try/catch — file I/O can fail
  • JSON files are great for prototypes; use SQLite or Postgres in production

What's Next

In Post #6, we start Module 2 — building a Dashboard app. We'll create a mini HTML templating engine using pure string interpolation to render our todo data as proper HTML pages.

Building along? Share on X/Twitter or GitHub.