The todo API is the "hello world" of backend development, and for good reason: build it once and you understand CRUD — Create, Read, Update, Delete — which is the shape of almost every API you'll ever write. This one is Express over a SQLite table, returns the right HTTP status codes, and is just two files on the Node.js application. It's a real backend you can point any front end at.
| At a glance | |
|---|---|
| You're building | A REST API for todos, backed by a database |
| You need | A Falix server running the Node.js application |
| Plan | Any — free runs while your session timer has time, premium runs 24/7 |
| Time | About twenty-five minutes |
New to Node here? Read Node.js on Falix first. Want the design ideas behind good APIs? Designing a small REST API is the companion.
What it does
| Route | Does | Success code |
|---|---|---|
GET /todos |
List all todos | 200 |
POST /todos |
Create one from { "title": "..." } |
201 |
GET /todos/:id |
Fetch one | 200 (or 404) |
PUT /todos/:id |
Update title and/or done |
200 (or 404) |
DELETE /todos/:id |
Remove one | 204 (or 404) |
The files
package.json:
{
"name": "todo-api",
"version": "1.0.0",
"private": true,
"main": "index.js",
"dependencies": {
"better-sqlite3": "^11.8.1",
"express": "^4.21.2"
}
}
index.js — the whole API:
const express = require('express');
const Database = require('better-sqlite3');
const db = new Database('todos.db');
db.exec(`CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
)`);
const app = express();
app.use(express.json());
// List every todo.
app.get('/todos', (req, res) => {
res.json(db.prepare('SELECT * FROM todos ORDER BY id').all());
});
// Create one.
app.post('/todos', (req, res) => {
const { title } = req.body;
if (!title || typeof title !== 'string') {
return res.status(400).json({ error: 'title is required' });
}
const info = db.prepare('INSERT INTO todos (title, created_at) VALUES (?, ?)')
.run(title, Date.now());
res.status(201).json(db.prepare('SELECT * FROM todos WHERE id = ?').get(info.lastInsertRowid));
});
// Read one.
app.get('/todos/:id', (req, res) => {
const todo = db.prepare('SELECT * FROM todos WHERE id = ?').get(req.params.id);
if (!todo) return res.status(404).json({ error: 'not found' });
res.json(todo);
});
// Update title and/or done.
app.put('/todos/:id', (req, res) => {
const todo = db.prepare('SELECT * FROM todos WHERE id = ?').get(req.params.id);
if (!todo) return res.status(404).json({ error: 'not found' });
const title = req.body.title !== undefined ? req.body.title : todo.title;
const done = (req.body.done !== undefined ? req.body.done : todo.done) ? 1 : 0;
db.prepare('UPDATE todos SET title = ?, done = ? WHERE id = ?').run(title, done, req.params.id);
res.json(db.prepare('SELECT * FROM todos WHERE id = ?').get(req.params.id));
});
// Delete one.
app.delete('/todos/:id', (req, res) => {
const info = db.prepare('DELETE FROM todos WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not found' });
res.status(204).end();
});
const PORT = process.env.SERVER_PORT || 8080;
app.listen(PORT, '0.0.0.0', () => console.log(`Listening on port ${PORT}`));
How it works
- One table, four verbs. Each HTTP method maps to one SQL operation:
GETselects,POSTinserts,PUTupdates,DELETEdeletes. That mapping is REST — learn it here and every CRUD API reads the same. - Status codes carry meaning.
201 Createdafter a POST (with the new row),204 No Contentafter a delete (nothing to return),404when an id doesn't exist,400when the request is missing atitle. A client can act on those codes without parsing the body. express.json()parses the body. Without it,req.bodyis undefined. It reads the incoming JSON into an object for you.?-placeholders everywhere. Every value passed to SQL goes through a?parameter, which is what keeps injection out — never build a query by gluing strings together.
🎯 Good to know:
doneis stored as0or1because SQLite has no boolean type. The update readsreq.body.doneif you sent one, otherwise keeps the current value, then coerces to0/1. Send{"done": true}to check a todo off.
Run it on Falix
- Upload
package.jsonandindex.js, or deploy from Git. - Press Start. After
npm install(Express andbetter-sqlite3, which ships prebuilt binaries — no compiler needed), the console printsListening on port …and the server goes online. - It reads
SERVER_PORTand binds0.0.0.0already, so it's live on your public port immediately.
Exercise the whole API with curl (use your address from the Network page):
# Create
curl -X POST http://YOUR_ADDRESS:PORT/todos \
-H "Content-Type: application/json" -d '{"title":"Buy milk"}'
# -> 201 {"id":1,"title":"Buy milk","done":0,"created_at":...}
# List
curl http://YOUR_ADDRESS:PORT/todos
# Mark done
curl -X PUT http://YOUR_ADDRESS:PORT/todos/1 \
-H "Content-Type: application/json" -d '{"done":true}'
# -> {"id":1,"title":"Buy milk","done":1, ...}
# Delete
curl -i -X DELETE http://YOUR_ADDRESS:PORT/todos/1
# -> HTTP/1.1 204 No Content
🎯 Good to know: Todos live in
todos.dbon the server. A restart keeps them; a reinstall or application switch wipes the file. For data that must survive anything, use a managed database, which runs on a shared host and outlives your server.
Make it yours
- A front end. This API is back-end-only by design. Point a static page or a React SPA at it, or serve a page from
public/withexpress.staticlike the URL shortener does. - Filtering and paging.
GET /todos?done=falseor?limit=20&offset=40— readreq.queryand adjust the SQL. - Per-user todos. Add a
user_idcolumn and an auth layer — Sessions vs JWT covers the honest minimum. - Validation. Reject blank or too-long titles with clear
400s before they hit the database.
Beyond the Falix layer this is plain Express and SQL — expressjs.com owns routing and middleware, and SQLite in depth covers the storage side.
Troubleshooting
title is required(400) — the body was empty or not JSON. SendContent-Type: application/jsonand atitle.- Everything returns 404 — you're hitting the wrong path (it's
/todos, plural) or the id doesn't exist.GET /todosto see what's there. Cannot find moduleon start — the install failed; read the npm output at the top of the console, or install from the Packages page and restart.- Reachable locally, not deployed — a port/bind problem. Use
SERVER_PORTand0.0.0.0; see I can't reach my app.