Two problems remain in the task API. The settings, such as the port number, are written inside the code, and the data vanishes every time the server restarts. In this lesson you will fix both. First you will learn about environment variables, the standard way to give a program its settings and secrets. Then you will move the data into a small store that saves tasks to a JSON file.
Why settings do not belong in code
Your program runs in different places: your laptop, a test machine and a live server. Each place needs different values: the port, the address of the database, the secret used for logins. If these values sit inside your code, you must edit the code for each place, and passwords end up in git, where many people can see them and where they stay in the history forever. The fix is to keep settings outside the code, in the environment, and have the code read them.
process.env
Every running program has a set of environment variables: named values given to it by the system that starts it. In Node.js they are in the object process.env. All values are strings, or undefined when missing. Read the port like this.
const port = Number(process.env.PORT) || 3000;
The Number(...) converts text to a number. If PORT is not set, Number(undefined) is NaN, which counts as false, so || 3000 supplies the default. Hosting services and process managers often set PORT for you, which is why the code should always read it.
A .env file
Typing variables in the terminal for every run is tedious. The usual solution is a text file named .env in the project folder, with one NAME=value per line. Recent versions of Node.js can load it directly with the flag --env-file=.env, which is what the start and dev scripts from lesson 2 use. You do not need an extra package.
# .env (never commit this file)
PORT=3100
JWT_SECRET=change-me-to-a-long-random-string
DATA_FILE=./data/tasks.json
- Add
.envto.gitignorenow, before your first commit. - Commit a copy named
.env.examplewith the variable names and harmless sample values, so other people know what to set. - No spaces around
=, and no quotes needed for simple values. - On a live server, do not upload a
.envfile at all if your host has a place to set environment variables. Use that instead.
Fail early when something is missing
If a required setting is missing, the program should stop at start-up with a clear message, and not fail in a confusing way later. You will see this in the final server.js in lesson 15, where a missing or short JWT_SECRET stops the server.
A store that saves to a file
Now the data. We move all work with tasks into a separate module, a store. The routes will call the store and will not care how data is kept. Today the store uses a file. In the next lesson, you can replace it with a database without changing the routes much. That is why separating the two is a good habit.
import { readFile, writeFile } from 'node:fs/promises';
// Keeps tasks in memory and, if you give it a file, saves after every change.
export function createStore(file) {
let tasks = [];
let nextId = 1;
const ready = file
? readFile(file, 'utf8')
.then((text) => {
tasks = JSON.parse(text);
nextId = tasks.reduce((max, t) => Math.max(max, t.id), 0) + 1;
})
.catch((err) => {
if (err.code !== 'ENOENT') throw err; // no file yet is fine, anything else is not
})
: Promise.resolve();
const save = () => (file ? writeFile(file, JSON.stringify(tasks, null, 2)) : Promise.resolve());
return {
async list() { await ready; return tasks; },
async get(id) { await ready; return tasks.find((t) => t.id === id); },
async create(data) {
await ready;
const task = { id: nextId++, title: data.title, done: false, createdAt: new Date().toISOString() };
tasks.push(task);
await save();
return task;
},
async update(id, data) {
await ready;
const task = tasks.find((t) => t.id === id);
if (!task) return undefined;
Object.assign(task, data);
await save();
return task;
},
async remove(id) {
await ready;
const before = tasks.length;
tasks = tasks.filter((t) => t.id !== id);
if (tasks.length === before) return false;
await save();
return true;
},
};
}
Take your time with this file. createStore(file) returns an object with five functions. The ready promise loads the file once at start-up. If the file does not exist, which is normal the first time, ENOENT is ignored and the list starts empty. Any other error is thrown, so a damaged file is not silently replaced. Each function starts with await ready, so requests that arrive during start-up wait for the data to load. After each change, save() writes the whole list to the file. The next id is the highest id so far plus one, so ids stay unique after a restart.
Now use the store in the server. The routes become short, because the store does the work and checkTask from lesson 10 does the validation. Put validate.js (from lesson 10) and store.js (this lesson's store) in the project folder next to server.js.
import express from 'express';
import { createStore } from './store.js';
import { checkTask } from './validate.js';
const port = Number(process.env.PORT) || 3000;
const store = createStore(process.env.DATA_FILE || './data/tasks.json');
const app = express();
app.use(express.json());
app.get('/api/tasks', async (req, res) => {
res.json(await store.list());
});
app.post('/api/tasks', async (req, res) => {
const { value, error } = checkTask(req.body);
if (error) return res.status(400).json({ error });
res.status(201).json(await store.create(value));
});
app.patch('/api/tasks/:id', async (req, res) => {
const { value, error } = checkTask(req.body, { partial: true });
if (error) return res.status(400).json({ error });
const task = await store.update(Number(req.params.id), value);
if (!task) return res.status(404).json({ error: 'Task not found' });
res.json(task);
});
app.delete('/api/tasks/:id', async (req, res) => {
const removed = await store.remove(Number(req.params.id));
if (!removed) return res.status(404).json({ error: 'Task not found' });
res.status(204).end();
});
app.listen(port, () => console.log(`Server running on port ${port}`));
Start the server with npm run dev, create a task, then stop the server and start it again. The task is still there, and data/tasks.json contains it.
curl -X POST http://localhost:3100/api/tasks -H "Content-Type: application/json" -d '{"title":"Saved"}'
curl http://localhost:3100/api/tasks
{"id":1,"title":"Saved","done":false,"createdAt":"2026-10-28T04:10:56.259Z"}
[{"id":1,"title":"Saved","done":false,"createdAt":"2026-10-28T04:10:56.259Z"}]
The dates in your output will show the time of your own request. Create the data folder first if it does not exist, because writeFile creates files but not folders.
Honest limits of a file store
- It rewrites the whole file on every change, so it slows down as data grows.
- If two changes arrive at the same moment, the second write can overwrite the first. Writes of tiny files are quick, so this is rare in a practice project, but it is a real risk on a busy server.
- It lives on one machine. If you run several copies of the server, each has its own data.
- There are no searches, indexes or relationships.
- On many hosting services, the file system is wiped on every deploy. A file store is for learning and tiny tools. Anything that matters belongs in a database, which is the next lesson.
Your task
- Create
.envand.env.example, and add.envto.gitignore. - Use
process.env.PORTin the server and start it on a different port by changing.env. - Add
store.jsand move the routes to use it. Create a task, restart the server and check that it survived. - Delete
data/tasks.jsonand start the server. Check it starts with an empty list and does not crash.
How to check it worked
npm run devstarts on the port from.env.- A task created before a restart is still listed after it.
git statusdoes not list.env.