An API that forgets everything on restart is a demo, not a product. Before adding a real database, learn the simplest form of persistence: a JSON file on disk.
Learning objectives
- Persist an array of records to a JSON file.
- Read and write safely with
fs. - Keep configuration and secrets in
.env. - Add filtering, sorting and pagination to a JSON-backed API.
1. The idea
data/posts.json ←→ readPosts() / writePosts() ←→ Express routes
Every request reads the file, changes the array, writes it back. It is not fast and it does not handle concurrent writers, but it is honest, dependency-free persistence and it teaches the shape every database layer has.
2. Read and write helpers
const fs = require("node:fs");
const path = require("node:path");
const DATA_DIR = path.join(__dirname, "data");
const DB_PATH = path.join(DATA_DIR, "posts.json");
function readPosts() {
if (!fs.existsSync(DB_PATH)) return [];
try {
return JSON.parse(fs.readFileSync(DB_PATH, "utf8"));
} catch (error) {
console.error("Could not read the database:", error.message);
return [];
}
}
function writePosts(posts) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(DB_PATH, JSON.stringify(posts, null, 2), "utf8");
}
Two details matter:
JSON.parsethrows on a corrupted file. Catch it and fail gracefully.- Create the directory before writing; a missing folder is the most common first-run error.
For production-grade safety, write to a temporary file and rename it over the original — rename is atomic on the same disk, so a crash never leaves half a JSON document.
3. Environment variables
Configuration must not live in code.
npm install dotenv
# .env
PORT=4000
NODE_ENV=development
require("dotenv").config();
const PORT = process.env.PORT || 3000;
const env = process.env.NODE_ENV || "development";
Add .env to .gitignore before the first commit. Commit a .env.example with the variable names and no values.
4. Pagination and filters
app.get("/api/posts", (req, res) => {
let posts = readPosts();
const { author, tag, page, limit } = req.query;
if (author) {
posts = posts.filter((p) => p.author.toLowerCase().includes(String(author).toLowerCase()));
}
if (tag) {
posts = posts.filter((p) => Array.isArray(p.tags) && p.tags.includes(tag));
}
posts.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
if (page && limit) {
const pageNum = Math.max(1, Number(page));
const limitNum = Math.max(1, Number(limit));
const start = (pageNum - 1) * limitNum;
return res.json({
success: true,
total: posts.length,
page: pageNum,
totalPages: Math.ceil(posts.length / limitNum),
data: posts.slice(start, start + limitNum),
});
}
res.json({ success: true, count: posts.length, data: posts });
});
Pagination belongs on the server: sending 10,000 records to the browser and hiding most of them wastes bandwidth and memory.
5. Updates that keep history
posts[index] = {
...posts[index],
title: title.trim(),
content: content.trim(),
updatedAt: new Date().toISOString(),
};
Spread the old record first so id and createdAt survive. Replace only the fields the client is allowed to change.
Common mistakes
- Committing
data/posts.jsonwith real user data. - Forgetting to write after a mutation — the response looks right, then the data disappears on restart.
- Trusting
pageandlimitwithout converting and clamping them. - Storing secrets in
package.jsonor in source files.
Practice
Build the posts API in the assignment. When it works, the next subject replaces the JSON file with SQLite and you will recognise every layer.