Chapter 16 of 18 · Node.js for Beginners — Complete Free Tutorial

Persisting Data with a JSON Store

Restart the server and every todo disappears — chapter 15's in-memory array is a demo, not a product. The Node.js for Beginners series now gives the project durable storage: a JSON file behind a small module with an explicit interface. No database server to install, readable data you can diff, and an architecture that lets you swap in SQLite or PostgreSQL later without touching a route.

Why a JSON file (for now)

OptionSetupBest when
JSON fileNone — fs onlyLearning CRUD, single process, small data
SQLiteOne packageReal queries needed, still zero servers
PostgreSQL / MySQLServer + credentialsMultiple writers, concurrent traffic, real production
MongoDBServer or AtlasDocument-shaped data that resists relational modelling

The rule: start boring, abstract early. A file is perfect today as long as every read and write goes through one module — the seam you can cut later.

Project layout

todo-api/
├── data/
│   └── todos.json      # the database — gitignore it or seed it deliberately
├── src/
│   ├── server.js       # routes, now calling the store
│   └── store.js        # ALL file access lives here
└── package.json

One rule keeps this honest: only store.js imports fs. Routes never touch paths or parsing; when PostgreSQL arrives, only this file changes.

The store module

// src/store.js
import { readFile, writeFile, rename, mkdir } from 'node:fs/promises';
import path from 'node:path';

const FILE = path.join(import.meta.dirname, '..', 'data', 'todos.json');

export async function all() {
  try {
    return JSON.parse(await readFile(FILE, 'utf8'));
  } catch (err) {
    if (err.code === 'ENOENT') return [];      // first run: empty store
    throw err;                                 // corrupt file must surface
  }
}

export async function save(todos) {
  await mkdir(path.dirname(FILE), { recursive: true });
  const temp = FILE + '.tmp';
  await writeFile(temp, JSON.stringify(todos, null, 2) + '\n', 'utf8');
  await rename(temp, FILE);                    // atomic replace — no half-written file
}

Expected output: after one save, data/todos.json contains pretty-printed JSON with a trailing newline — friendly to git diffs. The write-to-temp-then-rename trick matters: a crash mid-write truncates .tmp, never your data file.

Wiring the routes

Each handler now awaits the store. The route signatures do not change — only what they call.

app.get('/api/todos', async (req, res, next) => {
  try {
    const todos = await store.all();
    res.json({ count: todos.length, todos });
  } catch (err) {
    next(err);            // forward to the chapter 14 error handler
  }
});

app.post('/api/todos', async (req, res, next) => {
  try {
    const todos = await store.all();
    const todo = { id: nextId(todos), title: req.body.title.trim(), done: false,
                   createdAt: new Date().toISOString() };
    todos.push(todo);
    await store.save(todos);
    res.status(201).location(`/api/todos/${todo.id}`).json(todo);
  } catch (err) {
    next(err);
  }
});

Expected output: create a todo, restart the process, GET the list — it is still there. That single sentence is the whole chapter. On Express 5, async handlers that throw reach the error middleware automatically, so the try/catch wrappers shrink to nothing.

The file, up close

[
  {
    "id": 1,
    "title": "Design the API contract",
    "done": true,
    "createdAt": "2026-10-03T09:00:00.000Z"
  },
  {
    "id": 2,
    "title": "Persist the data",
    "done": false,
    "createdAt": "2026-10-03T09:05:00.000Z"
  }
]

Humans can read it, git can diff it, and a broken edit shows up as a parse error you catch in all() instead of a mystery outage. Keep a seed() helper that writes a starter array so a fresh clone starts with something to click on.

Limits you must not ignore

  • Read-modify-write of the whole file — fine for hundreds of rows, quadratic pain at millions.
  • One writer at a time — two concurrent POSTs can interleave and lose an update; a single-process queue or proper-lockfile guards it while the dataset is small.
  • No querying — filtering happens in JavaScript over the full array; move to SQLite when that shows.
  • Backups are your job — a file has no replication; copy it or move to a managed database before real users arrive.

Common mistakes

  • Forgetting await store.save() — the response claims success while the write is still queued; crash means data loss.
  • Swallowing parse errors — treating a corrupted file as empty silently deletes your data on the next save.
  • Paths anchored to cwd() — the file lands wherever the process started; anchor to the module (import.meta.dirname).
  • Writing without the temp-plus-rename dance — a crash at the wrong moment truncates the store.
  • Letting routes import fs directly — the abstraction leaks and the future swap becomes a rewrite.

Practice checklist

  1. Build store.js with all() and save(), then route both read endpoints through it.
  2. Add create(), update() and remove() so no route manipulates the array itself.
  3. Delete todos.json and verify the app recreates an empty store without crashing.
  4. Write invalid JSON into the file and confirm your code reports the problem instead of wiping it.
  5. Create, restart, and verify persistence; then write an npm run seed script.

Your data survives — now let a browser touch it. Next: Connecting a Frontend: CORS and Fetch.

Quiz: Persisting Data with a JSON Store

5 questions · 70% to pass

Frequently Asked Questions

Node.js is a runtime that executes JavaScript outside the browser. It combines Google's V8 engine with APIs for files, networks, processes and streams, plus a package ecosystem (npm). You use it to build servers, command-line tools, APIs and anything that needs JavaScript running on a machine rather than in a page.

JavaScript is the language; Node.js is one environment that runs it. In the browser the language comes with a DOM, events and window APIs — in Node those are replaced by the file system, network and process modules. Syntax, functions, arrays, promises and async/await are identical, so your JavaScript knowledge transfers directly.

Browser code is sandboxed: it cannot read your files or open arbitrary ports. Node code has full machine access, which is why it is used for servers and tools but also why you never run untrusted code in it. There is no document or window in Node — the equivalents are global, process and modules.

npm is JavaScript's package manager and public registry. package.json describes your project: name, entry point, dependencies with version ranges, and scripts such as npm run dev. Installing a package copies it into node_modules and records the exact intent in package-lock.json so every machine installs identical versions.

Express is a minimal web framework for Node: a few thousand lines that give you routing, middleware, request parsing and static files on top of the raw HTTP server. It is the most common starting point for REST APIs and the backbone of this tutorial's project chapters.

Both work. CommonJS (require/module.exports) is the classic Node format; ES modules (import/export) are the standard and activated in a package.json with "type": "module". Modern projects favour ESM for browser compatibility, but you will read both in existing code, so this course teaches each.

Middleware are functions that run between the incoming request and the final response, in order: body parsing, logging, authentication, rate limits, error handling. Each receives the request, response and next function and can respond itself or pass control along — Express's pipeline model in one sentence.

A JSON file is perfect for learning CRUD: no setup, readable data, easy to reset. The moment you need concurrent writes, querying or multiple users, move to a real database — MySQL/PostgreSQL for relational data or MongoDB for documents. The storage chapter isolates persistence behind one module so swapping it later is straightforward.

A REST API exposes resources over HTTP using verbs on URLs: GET /api/todos lists, POST creates, PUT or PATCH updates, DELETE removes, and everything answers with JSON plus correct status codes (200, 201, 400, 404, 500). It is stateless — each request carries its own authentication — which makes it easy to cache, scale and consume from any frontend.

Node apps are long-running processes, not static files: deploy them to a Node-friendly host (a VPS, Render, Railway or Fly.io), keep secrets in environment variables, terminate HTTPS at a reverse proxy or platform edge, and send logs somewhere durable. The final chapter covers the full checklist including health checks and graceful shutdown.