Build a todo REST API

The classic CRUD API done right — Express and SQLite give you GET, POST, PUT, and DELETE over a todos table, with the correct status codes. Two files, verified with curl, deploy and extend.

Skip the setup This guide has a one-click starter template that installs everything below onto your server.

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: GET selects, POST inserts, PUT updates, DELETE deletes. That mapping is REST — learn it here and every CRUD API reads the same.
  • Status codes carry meaning. 201 Created after a POST (with the new row), 204 No Content after a delete (nothing to return), 404 when an id doesn't exist, 400 when the request is missing a title. A client can act on those codes without parsing the body.
  • express.json() parses the body. Without it, req.body is 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: done is stored as 0 or 1 because SQLite has no boolean type. The update reads req.body.done if you sent one, otherwise keeps the current value, then coerces to 0/1. Send {"done": true} to check a todo off.

Run it on Falix

  1. Upload package.json and index.js, or deploy from Git.
  2. Press Start. After npm install (Express and better-sqlite3, which ships prebuilt binaries — no compiler needed), the console prints Listening on port … and the server goes online.
  3. It reads SERVER_PORT and binds 0.0.0.0 already, 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.db on 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/ with express.static like the URL shortener does.
  • Filtering and paging. GET /todos?done=false or ?limit=20&offset=40 — read req.query and adjust the SQL.
  • Per-user todos. Add a user_id column 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. Send Content-Type: application/json and a title.
  • Everything returns 404 — you're hitting the wrong path (it's /todos, plural) or the id doesn't exist. GET /todos to see what's there.
  • Cannot find module on 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_PORT and 0.0.0.0; see I can't reach my app.

Next steps

Was this guide helpful?