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
.tmpthenrenamefor 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.