Task API Tutorial
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.
- POST /tasks
- 201 Created
- GET /tasks/1
- 200 OK
- GET /tasks/999
- 404
✓ POST create + GET by id
✓ Real JSON middleware
✓ Honest 400 / 404 errors
✓ The full request pipeline
Task API Tutorial
Overview · 3 parts
Finished project
By the end, port 8080 answers like this:
- POST/tasks
{"id":1,"title":"Write the docs","done":false}
201Created - GET/tasks
[{"id":1,"title":"Write the docs","done":false}]
200OK - GET/tasks/1
{"id":1,"title":"Write the docs","done":false}
200OK - GET/tasks/999
honest miss — not a fake empty body
404Not Found
The same flow, as curl commands:
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 FoundThe 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?
→ Quick Start — scaffold with create-nextrush.
The request pipeline
Every request above walks this path. Each part lights up one more stage:
Full shape (you'll build it)
- Adapter
- Middleware
- Router
- Handler
- Context
| Part | Lights up |
|---|---|
| 1 | Adapter → Router → Handler |
| 2 | + Middleware (json()) · ctx.body |
| 3 | + route params · thrown 404 |
Same shape as every NextRush app — not Task-API-specific.
Why a Task API
A one-route hello-world stops too early. A Task API forces four backend concerns at once:
- Storage — remember state between requests
- Input — parse and validate a POST body before you trust it
- Lookup — resolve a route param, or fail honestly
- Errors — real HTTP
400/404, not silent bugs
Build those four once; Users and Orders reuse the same shape.
Prerequisites
Routing and Context are introduced as you need them. Beginner · ~20 minutes · 5 steps.
Create the application
- Part 1
- Part 2
- Part 3
An empty folder becomes a running server that answers one request — proof the wiring works before any business logic.
- Project setup
- Router
- ctx.json
Goal: Adapter → Router → Handler end to end. No storage, no body parsing yet.
By the end of this part
- Create a NextRush app from a normal Node project
- Mount a router and return JSON
- See the spine of every request path
Pipeline this part builds
- Adapter
- Router
- Handler
- Middleware
- Errors
Step 1 — Create the project
Goal: NextRush is a library you import — the folder and package.json come first.
mkdir task-api && cd task-api
pnpm initAdd "type": "module" to package.json. NextRush is ESM-only; without that line, import
fails later as a confusing SyntaxError.
How you know it worked: "type": "module" is present in package.json.
Step 2 — Install NextRush
Goal: the framework must be on disk before Step 3 can import it. Install body-parser here too (used in Part 2) so installs stay in one place.
$ pnpm add nextrush @nextrush/body-parser$ pnpm add -D tsx typescript @types/node
- ✓
nextrush—createApp,createRouter,listen - ✓
@nextrush/body-parser— JSON parsing in Part 2 - ✓
tsx— run TypeScript while you iterate
Why it works
- Body parsing is a separate package so the core stays zero-dependency
- You only pay for JSON parsing on routes that need it
Step 3 — Start a server with one route
New idea: prove request → response before storage or input.
import { createApp, createRouter, listen } from 'nextrush';
const app = createApp();
const router = createRouter();
router.get('/', (ctx) => {
ctx.json({ name: 'Task API', status: 'ok' });
});
app.route('/', router);
await listen(app, 8080);npx tsx src/index.tscurl http://localhost:8080/
# → {"name":"Task API","status":"ok"}Server silence is expected — the default logger is a no-op.
Why it works
createApp()owns the middleware pipelinecreateRouter()holds routes;app.route('/', router)mounts it- Handlers write through Context —
ctx.json(...), never a return value
What changed
Before
empty folder
After
Adapter → Router → Handler → ctx.json
The handler never returns the response — Context is how you write it, every time.
You have a running server
- Server starts and stays up
- Responds to a real HTTP request
- Routes
GET /through a mounted router - Returns JSON through
ctx.json(...)
Next → Accept a JSON body and store a task
Accept data
- Part 1
- Part 2
- Part 3
This is where your API becomes real — middleware parses the body, a store remembers tasks, and bad input gets a real 400.
- Request body
- Middleware
- ctx.body
- 400
Goal: fixed-response server → one that remembers something.
By the end of this part
- Parse JSON with middleware
- Read
ctx.bodyin a handler - Store state between requests
- Reject bad input with a real
400
Pipeline this part builds
- Adapter
- Middleware
- Router
- Handler
- Errors
Step 4 — Accept data with POST
How it fits: reading a body is where the middleware box earns its place.
This step adds
- ✓ In-memory
Mapas the store - ✓
app.use(json())before the routes - ✓
POST /tasks+GET /tasks(replaces the Part 1GET /demo route)
const app = createApp();
+ app.use(json());
const router = createRouter();
+ router.post('/tasks', (ctx) => { ... });
+ router.get('/tasks', (ctx) => { ... });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 RequestWhy it works
- Order is Adapter →
json()fillsctx.body→ Router → Handler - Pipeline runs top to bottom —
app.use(json())must sit aboveapp.route(...) createApp()owns middleware;createRouter()only matches paths
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.
You made the jump from demo to API
- Parsed JSON before any route
- Stored data across requests
- Middleware running in order
- Real
400on missingtitle
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.)
Read it back, honestly
- Part 1
- Part 2
- Part 3
Prove the store works — and prove a missing id fails honestly instead of a misleading empty 200.
- Route params
- Lookup
- NotFoundError
- 404
Goal: create → read back → honest miss.
By the end of this part
- Use a route parameter (
:id) - Look up a record from the store
- Throw
NotFoundErrorfor a real404
Pipeline this part completes
- Adapter
- Middleware
- Router
- Handler
- Errors
Step 5 — Read it back, or return a real 404
What changed: storing only matters if you can fetch — and a miss must not look like success.
This step adds
- ✓
GET /tasks/:id - ✓
NotFoundErrorwhen the lookup misses
router.get('/tasks', (ctx) => {
ctx.json([...tasks.values()]);
});
+ router.get('/tasks/:id', (ctx) => {
+ const task = tasks.get(Number(ctx.params.id));
+ if (!task) throw new NotFoundError('Task not found');
+ ctx.json(task);
+ });import { createApp, createRouter, listen, BadRequestError, NotFoundError } 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();
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);
});
router.get('/tasks/:id', (ctx) => {
const task = tasks.get(Number(ctx.params.id));
if (!task) throw new NotFoundError('Task not found');
ctx.json(task);
});
app.route('/', router);
await listen(app, 8080);Restart, then:
curl -X POST http://localhost:8080/tasks \
-H 'Content-Type: application/json' -d '{"title":"Write the docs"}'
# → {"id":1,...}
curl http://localhost:8080/tasks/1
# → {"id":1,...}
curl -i http://localhost:8080/tasks/999
# → HTTP/1.1 404 Not FoundWhy it works
:id→ctx.params.id(string) →Number(...)for the store key- Throw
NotFoundError— built-in handler maps it to status + JSON - Same Router / Handler shape as Parts 1–2 — one more route and a real failure path
What changed
Before
create + list only
After
create + list + GET /tasks/:id → 200 or 404
Throwing HttpError is enough — you never write status-mapping code by hand.
How you know it worked: known id → 200 with the task; /tasks/999 → 404; /tasks/abc →
clean 404 (not a crash).
The pipeline is real, running code
- Create → read back works end to end
- Missing id returns a real
404 - Route params, store, and errors share one shape
Next → Graduate — compare the finished file and extend it
task-api/
package.json
src/
index.tsIf POST is empty, re-check app.use(json()) above the router. If a stored task 404s, re-check
Number(ctx.params.id) against the id POST returned.
Final project
🎉 You built it
You built a working Task API
- Create tasks (
POST→ 201) - List and read by id
- Honest 400 / 404 handling
- JSON middleware in the pipeline
- Full request lifecycle wired by hand
- One mental model for every future route
Architecture recap
One last mental model — every box from Parts 1–3, live:
- Adapter
- Middleware
- Router
- Handler
- Context
import { createApp, createRouter, listen, BadRequestError, NotFoundError } 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();
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);
});
router.get('/tasks/:id', (ctx) => {
const task = tasks.get(Number(ctx.params.id));
if (!task) throw new NotFoundError('Task not found');
ctx.json(task);
});
app.route('/', router);
await listen(app, 8080);Run it in the browser, no local setup:
Try: empty list → create → read by id → GET /tasks/999 for the honest 404.
Common mistakes
Forgot middleware (or registered it too late)
- Symptom:
- POST handler sees an empty body and throws title is required for a valid request
- Cause:
- Body parser ran after the route, or was never registered
✅ Fix: Call app.use(json()) before mounting the router
404 confusion — 200 with null
- Symptom:
- GET /tasks/999 returns 200 with null instead of 404
- Cause:
- Handler sent the lookup result without checking it
✅ Fix: Throw NotFoundError when the store has no entry for that id
Router never mounted
- Symptom:
- Server starts but every request 404s — even GET /
- Cause:
- createRouter() without app.route(...)
✅ Fix: Add app.route('/', router) before listen(app, 8080)
DELETE returns a body with 204
- Symptom:
- Client chokes on a JSON body after DELETE /tasks/1
- Cause:
- 204 No Content must not include a response body
✅ Fix: Set ctx.status = 204 and ctx.send('') — no ctx.json(...)
What you built
- ✓ Application —
createApp(), owns the middleware pipeline - ✓ Router —
createRouter(), mounted withapp.route() - ✓ Store — in-memory
Mapacross requests - ✓ Three routes —
GET /tasks,POST /tasks,GET /tasks/:id - ✓ Two error paths — real
400and real404
What you learned
Mental models (not the file list):
- Request pipeline — Adapter → Middleware → Router → Handler → Context
- Routing — params + mount — Routing
- Context —
ctx.body,ctx.params,ctx.json(),ctx.status— Context - Middleware order — parser before router or
ctx.bodyis empty — Middleware - Errors — throw
HttpError, framework maps status — Error handling
A bigger app is this same pipeline with more middleware and more routes — not a different shape.
🎓 Think like an architect
You've finished building. Now explain why it works — that's what separates copying from understanding.
app.route('/', router) keeps task routes in one module you can test and reuse. Mount at /api
later without rewriting handlers.
json() runs for every request before matching. Inside a handler is too late; after
app.route(...) never runs for mounted routes.
Callers check status first. A 200 with null looks like success to caches, proxies, and client
libraries that only inspect the status line.
Try it yourself
Extend the API at your level — Starter through Expert if you can. Don't peek until you've tried. Each solution builds on the one before it.
Add DELETE /tasks/:id
Remove a task. Real 404 if missing. 204 on success.
Add PUT /tasks/:id
Replace title and done. Keep the same id. 404 if missing.
Move storage into a repository
Extract the Map behind TaskRepository with create / findById / delete. Handlers never
touch the Map.
Extract shared middleware
Logger middleware that prints method + path. Keep json() first so bodies still parse.
Solutions
Peek only after you've tried. Each builds on the previous level.
Solution — Starter (DELETE)
router.delete('/tasks/:id', (ctx) => {
const existed = tasks.delete(Number(ctx.params.id));
if (!existed) throw new NotFoundError('Task not found');
ctx.status = 204;
ctx.send('');
});Same three ideas: :id, the store, thrown NotFoundError. New detail: 204 No Content.
curl -i -X DELETE http://localhost:8080/tasks/1
# → HTTP/1.1 204 No Content
curl -i -X DELETE http://localhost:8080/tasks/999
# → HTTP/1.1 404 Not FoundSolution — Intermediate (PUT)
router.put('/tasks/:id', (ctx) => {
const id = Number(ctx.params.id);
const existing = tasks.get(id);
if (!existing) throw new NotFoundError('Task not found');
const body = ctx.body as { title?: string; done?: boolean };
if (!body?.title) throw new BadRequestError('title is required');
const updated: Task = { id, title: body.title, done: body.done ?? existing.done };
tasks.set(id, updated);
ctx.json(updated);
});PUT replaces fields on an existing record — same id, new title and done. Missing id still throws
NotFoundError; bad body still throws BadRequestError.
curl -X PUT http://localhost:8080/tasks/1 \
-H 'Content-Type: application/json' \
-d '{"title":"Ship the tutorial","done":true}'
# → {"id":1,"title":"Ship the tutorial","done":true}Solution — Advanced (repository)
Move the Map behind a factory so handlers talk to methods, not storage:
export type = { : number; : string; : boolean };
export function () {
const = new <number, >();
let = 1;
return {
(: string): {
const : = { : ++, , : false };
.(., );
return ;
},
(: number): | undefined {
return .();
},
(): [] {
return [....()];
},
(: number): boolean {
return .();
},
};
}
const repo = ();Handlers shrink to lookups and throws — no Map or nextId in the route file:
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:
import type { Context } from 'nextrush';
export function requestLogger() {
return async (ctx: Context) => {
console.log(`→ ${ctx.method} ${ctx.path}`);
await ctx.next();
};
}import { requestLogger } from './middleware/request-logger.js';
const app = createApp();
app.use(json()); // parse bodies before anything that reads ctx.body
app.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 }).
Next tutorial
⭐ Build a REST API — recommended next
Same shape further: full CRUD, validation, cleaner route layout.
Quick Start — scaffold with create-nextrush
Generate a project structure, then build on top of it.
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.
Continue learning
One recommended path, then depth when you need it:
⭐ Routing — recommended next concept
Segment trie, path params — the idea behind Parts 1 and 3.
Middleware
Why order is the trap that turns a working parser into undefined.
Context
One ctx for input and output — every handler already uses it.
Validation
Schema-check the body instead of hand-checking title.
Next.js
Mount a real NextRush application inside a Next.js App Router route handler — one function, seven HTTP methods, zero request rewriting.
Benchmarks
NextRush v4 performance dashboard — head-to-head HTTP throughput vs Fastify, Hono, Koa, Express, and a raw Node.js baseline. Interactive charts, heatmap, scenario explorer, and reproducible methodology.