open atlas
← All projects

backend · starter · 4d

Mini CRUD API

Build your first real backend: a tiny HTTP API that creates, reads, updates, and deletes notes — backed by SQLite so the data survives a restart. You go from a one-line 'hello' server to a small service that validates input and stores rows, one honest step at a time.

Almost every backend job, no matter how fancy, is CRUD with a costume on. Building one by hand — server, routing, validation, a real database — teaches you the moving parts that frameworks later hide. Resist the urge to install a big framework on day one. Get the raw loop working, watch a bad request bounce off your validation, and restart the server to see your rows survive. That moment, when the data is still there, is when a backend stops being magic.

Deliverable

A running Node service exposing GET/POST/PUT/DELETE for a 'notes' resource, persisting to a SQLite file, rejecting bad input with clear 4xx errors, and returning JSON the way real APIs do.

Milestones

0/5 · 0%
  1. 01A server that answers

    Start the smallest possible Node HTTP server and make it reply to a request. Run it, open the URL in your browser or with curl, and see your own words come back. This is the whole point of a backend in miniature: a process that listens and responds. Don't add a framework yet — get the bare loop working so you understand what every framework is hiding from you.

    Definition of done
    • Running the file starts a server on a port and prints which port it's listening on.
    • A request to GET / returns a 200 with some text or JSON you wrote.
  2. 02Route by method and path

    One endpoint isn't an API. Branch on the request's method and URL so that GET /notes lists notes and POST /notes is treated differently. For now keep the notes in a plain in-memory array — no database yet. The lesson here is that routing is just 'look at method + path, decide what to do', and that an unknown route should answer 404, not crash.

    Definition of done
    • GET /notes returns the current list as JSON; POST /notes adds an item to the in-memory array.
    • An unknown path (e.g. GET /nope) returns 404 instead of hanging or erroring.
  3. 03Read and validate the body

    A POST carries a body, and bodies arrive as chunks you have to collect and parse. Read the request body, JSON.parse it, and check it: a note must have a non-empty title. If it doesn't, answer 400 with a short message — never let bad data into your list. This is your first taste of the rule every backend lives by: trust nothing the client sends.

    Definition of done
    • POST /notes with a valid JSON body creates a note and returns 201 with the created note.
    • POST /notes with a missing or empty title returns 400 and does not add anything.
  4. 04Make it survive a restart

    An in-memory array forgets everything when the process stops. Swap it for a SQLite file: create a 'notes' table with an id and a title, and rewrite your handlers to INSERT, SELECT, UPDATE, and DELETE rows. SQLite is a single file and needs no server, so it's the perfect place to meet real persistence. Stop and restart your server — your notes should still be there.

    Definition of done
    • Notes are stored in a SQLite file; after restarting the server, GET /notes still returns them.
    • Each note has a real id generated by the database, used in the URL for single-note operations.
  5. 05Close the CRUD loop

    Finish the four verbs so the resource behaves the way clients expect. GET /notes/:id returns one note (or 404), PUT /notes/:id updates it, and DELETE /notes/:id removes it (or 404 if it never existed). Be honest with status codes — 200 for a good read, 404 when the id isn't there — because correct codes are how an API talks to the rest of the world without words.

    Definition of done
    • All four operations work against a real id: read one, update, delete, list.
    • Operating on a non-existent id returns 404, not a 500 or a silent success.

Starter

  • README.md
  • src/api.ts
  • test/api.test.ts
Download starter (.zip)

Unzip, implement the stubs, then run the tests until they pass: bun test

Rubric

Junior Mid Senior
Correctness of CRUD routes and status codes All five route shapes respond without crashing; 201 on create and 200 on reads are mostly right, though 404 vs 500 for missing ids may be confused. Every route returns the semantically correct status: 201 with the created body, 200 with the updated body on PUT, 204 or 200 on DELETE, and 404 whenever the id is absent — no accidental 500s. Status codes are correct AND the reasoning is explicit: 201 signals a new resource was allocated (the Location header belongs here), PUT is idempotent so repeated calls return the same 200 body, DELETE on a missing id is idempotent too — a second delete is 404, not a silent 200 that lies about what happened.
Validation and error paths (400 vs 404) Missing name on POST returns something other than 201; the distinction between 400 (bad input) and 404 (missing resource) is present but not always consistent. 400 fires exactly for malformed or missing required input (no name on POST) and never for a resource-not-found case; 404 fires exactly when the id does not exist in any route that references one. The two error classes are cleanly separated. The 400 response body names the failing field and the constraint violated (not a generic 'bad request'), so a client can surface a meaningful form error without parsing the message. PUT on a missing id is 404, not 400 — the body was valid, the address was wrong. You can articulate why conflating these two codes breaks typed API clients that branch on status to decide whether to retry or to show a validation UI.
Statelessness and what changes with a real database Recognizes that the in-memory map is lost on process restart and that a database would solve this. Can enumerate the concrete changes: id generation moves from an in-process counter to a database sequence or UUID, every handler becomes async, errors from the DB layer (constraint violations, connection drops) need their own error-path branches separate from the 400/404 logic. Identifies the concurrency hazard the in-memory map silently avoids: a real database with concurrent writers needs a transaction around PUT's read-then-write to prevent a lost update, and DELETE must be inside the same transaction as the existence check to avoid a TOCTOU race. The in-memory version is correct only because it is single-threaded; the same code structure breaks under a connection pool. You can name which SQL isolation level closes the race and what the performance cost is.
Reference walkthrough (spoiler)

Why 404 and not 400 for a missing id: 400 means the request itself is malformed — the client sent bad data. 404 means the request was valid but the addressed resource does not exist. Confusing them breaks any client that branches on status to decide whether to show a validation error (400) or a 'not found' page (404). The same logic applies to PUT and DELETE on a non-existent id: the body or method may be perfectly valid; the id simply does not map to a resource.

Why PUT and DELETE are idempotent: calling PUT /items/5 with the same body ten times must produce the same result as calling it once — the item is in the updated state. This is the RFC 9110 contract that lets a client safely retry a timed-out request without checking whether the first one landed. DELETE is idempotent by the same contract, but the status on the second call is 404, not 200 — the operation succeeded (delete-if-exists is deterministic), but there is nothing left to confirm. Distinguishing idempotency from the response code it produces is a common senior-interview question.

Why the in-memory counter makes tests deterministic: using Date.now() or Math.random() for id generation makes the test output depend on wall-clock time or random seed, so two runs of the same test can assert on different ids. An incrementing counter scoped to the store produces '1', '2', '3' in insertion order, making every assertion predictable. When you swap the store for a real database the id generation moves to a DB sequence or UUID — both are also deterministic within a transaction, though UUIDs are not ordered.

What changes when the in-memory map becomes a real database: every handler must become async, id generation moves out of process, and concurrent writers expose a lost-update hazard on PUT's read-then-write that the single-threaded in-memory version silently avoids. The fix is a transaction around the read and the update with at least READ COMMITTED isolation. DELETE has the same TOCTOU race: check-then-delete must be atomic, not two separate statements. A connection pool multiplies this risk because N concurrent requests share M connections, each carrying its own transaction state.

The role of the Store abstraction: encapsulating all state behind createStore() and handle() makes the implementation swappable without touching the tests. The acceptance suite imports the same interface whether the backing store is a Map, SQLite, or Postgres — only the createStore() factory changes. This is the repository pattern in miniature, and it is the same reason real frameworks separate the ORM model from the route handler: you want to test routing logic without spinning up a database.

Make it senior

  • Add input validation with a schema library (e.g. zod) so every field is checked in one place and the 400 response says exactly what was wrong.
  • Write a handful of automated tests that start the server, hit each endpoint, and assert the status code and body — your first real safety net.

Skills

starting a Node HTTP serverrouting by method and pathreading and validating a JSON bodyCRUD against SQLitereturning correct status codes

Suggested stack

nodesqlite (node:sqlite or better-sqlite3)