Build a Task API from an empty folder to a running server, and learn the request pipeline every NextRush app shares — routing, middleware order, context, and errors — along the way.
Tutorial · ~20 min
Build a real Task API from an empty folder — create, read, and honest 404s — and learn the
request pipeline every NextRush app shares.
curl -X POST http://localhost:8080/tasks \ -H 'Content-Type: application/json' \ -d '{"title":"Write the docs"}'# → {"id":1,"title":"Write the docs","done":false}curl http://localhost:8080/tasks# → [{"id":1,"title":"Write the docs","done":false}]curl http://localhost:8080/tasks/1# → {"id":1,"title":"Write the docs","done":false}curl -i http://localhost:8080/tasks/999# → HTTP/1.1 404 Not Found
The 999 miss returning a real 404 is the point — the GET hands back what the POST stored.
Hand-wired on purpose so router, store, and middleware order stay visible. Prefer a scaffold?
→ create-nextrush.
import { createApp, createRouter, listen, BadRequestError } from 'nextrush';import { json } from '@nextrush/body-parser';type Task = { id: number; title: string; done: boolean };const tasks = new Map<number, Task>();let nextId = 1;const app = createApp();// Parse JSON bodies before any route runs — without this, ctx.body stays undefined.app.use(json());const router = createRouter();router.get('/tasks', (ctx) => { ctx.json([...tasks.values()]);});router.post('/tasks', (ctx) => { const body = ctx.body as { title?: string }; if (!body?.title) throw new BadRequestError('title is required'); const task: Task = { id: nextId++, title: body.title, done: false }; tasks.set(task.id, task); ctx.status = 201; ctx.json(task);});app.route('/', router);await listen(app, 8080);
Restart (Ctrl+C, then npx tsx src/index.ts), then:
curl -X POST http://localhost:8080/tasks \ -H 'Content-Type: application/json' \ -d '{"title":"Write the docs"}'# → {"id":1,"title":"Write the docs","done":false}curl -X POST http://localhost:8080/tasks \ -H 'Content-Type: application/json' \ -d '{}'# → HTTP/1.1 400 Bad Request
💡 Why this works
Order is Adapter → json() fills ctx.body → Router → Handler
Pipeline runs top to bottom — app.use(json()) must sit aboveapp.route(...)
createApp() owns middleware; createRouter() only matches paths
Loading diagram...
What changed
Before
Adapter → Router → Handler
→
After
Adapter → json() → Router → Handler
Register the parser after the routes and ctx.body is still undefined when the handler runs.
If a POST handler sees ctx.body as undefined, the parser ran too late or wasn't registered.
app.use(json()) has to sit above the router — not inside a handler, not after app.route(...).
How you know it worked: a valid POST returns 201 with an id. Empty {} returns 400, not a
silent bad record.
✅ Part 2 complete
You made the jump from demo to API
You now have
✓Parsed JSON before any route
✓Stored data across requests
✓Middleware running in order
✓Real 400 on missing title
Next → Read a task back by id — and return a real 404 on a miss
🎉Part 2 complete — your API accepts JSON, stores tasks, and rejects bad input with a real 400.
Can you explain: why throw BadRequestError instead of ctx.json({ error: '...' }) with a 200?
(The HTTP status is what clients check first — burying the signal in a 200 body forces every
caller to re-implement what NextRush already gives you.)
You've finished building. Now explain why it works — that's what separates copying from understanding.
Architect challenge
1
Why create a router and mount it with app.route('/', router), rather than adding a
route straight to app?
2
Why does app.use(json()) have to sit above the router instead of inside a handler?
3
Why is a thrown NotFoundError better for a missing id than
ctx.json(null) with a 200?
Discussion notes — after you've answered yourself
Router mount — app.route('/', router) keeps task routes in one module you can test and
reuse. Mount at /api later without rewriting handlers.
Middleware above routes — json() runs for every request before matching. Inside a handler
is too late; after app.route(...) never runs for mounted routes.
Real 404 — Callers check status first. A 200 with null looks like success to caches,
proxies, and client libraries that only inspect the status line.
Returns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map.
@returnsReturns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.
Handlers shrink to lookups and throws — no Map or nextId in the route file:
src/index.ts (handlers use the repository)
import { createTaskRepository } from './task-repository.js';const tasks = createTaskRepository();router.get('/tasks', (ctx) => { ctx.json(tasks.findAll());});router.post('/tasks', (ctx) => { const body = ctx.body as { title?: string }; if (!body?.title) throw new BadRequestError('title is required'); const task = tasks.create(body.title); ctx.status = 201; ctx.json(task);});router.get('/tasks/:id', (ctx) => { const task = tasks.findById(Number(ctx.params.id)); if (!task) throw new NotFoundError('Task not found'); ctx.json(task);});
Same HTTP behavior — the repository is where you swap in-memory storage for a database later
without touching route handlers.
Solution — Expert (logger middleware)
Extract a small middleware function. Register json()first, then the logger, then routes:
src/middleware/request-logger.ts
import type { Context } from 'nextrush';export function requestLogger() { return async (ctx: Context) => { console.log(`→ ${ctx.method} ${ctx.path}`); await ctx.next(); };}
src/index.ts (middleware order)
import { requestLogger } from './middleware/request-logger.js';const app = createApp();app.use(json()); // parse bodies before anything that reads ctx.bodyapp.use(requestLogger());const router = createRouter();// ... routes unchanged ...
Every request now prints → GET /tasks/1 (or similar) before the handler runs. Bodies still
parse because json() sits above the logger — not inside a handler, not after app.route(...).
createApp()'s default logger is a no-op, so console.log is fine for this exercise. In
production, swap in @nextrush/logger or pass
createApp({ logger: console }).
🎉Tutorial completeYou can now build CRUD APIs, return honest HTTP errors, understand the request pipeline, and read NextRush code. Every bigger app is this same shape — more middleware, more routes, same pipeline.