Getting Started

Build a Task API

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.

  1. POST /tasks
  2. 201 Created
  3. GET /tasks/1
  4. 200 OK
  5. GET /tasks/999
  6. 404

POST create + GET by id

Real JSON middleware

Honest 400 / 404 errors

The full request pipeline

Beginner~20 minutesNode.js 22+TypeScript

Task API Tutorial

Overview · 3 parts

Finished project

By the end, port 8080 answers like this:

Task API · live shapelocalhost:8080
  • 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
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 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.

The request pipeline

Every request above walks this path. Each part lights up one more stage:

Full shape (you'll build it)

  1. Adapter
  2. Middleware
  3. Router
  4. Handler
  5. Context
PartLights up
1Adapter → 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

Concepts

Routing and Context — introduced as you need them

Required

Node.js 22+, package manager, basic TypeScript

Difficulty · time

Beginner · ~20 minutes · 5 steps


Part 1 — Create the application

Task API Tutorial

Part 1 of 3 · Create the application

Part 1 of 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

  1. Adapter
  2. Router
  3. NewHandler
  4. Middleware
  5. Errors

Step 1 / 5 — Create the project

Why now: NextRush is a library you import — the folder and package.json come first.

mkdir task-api && cd task-api
pnpm init

Add "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 / 5 — Install NextRush

Why now: 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
  • nextrushcreateApp, createRouter, listen
  • @nextrush/body-parser — JSON parsing in Part 2
  • tsx — run TypeScript while you iterate

💡 Why this 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 / 5 — Start a server with one route

Why now: prove request → response before storage or input.

src/index.ts
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.ts
curl http://localhost:8080/
# → {"name":"Task API","status":"ok"}

Server silence is expected — the default logger is a no-op.

💡 Why this works

  • createApp() owns the middleware pipeline
  • createRouter() holds routes; app.route('/', router) mounts it
  • Handlers write through Contextctx.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.

✅ Part 1 complete

You have a running server

You now have

  • 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

Part 1 complete — Adapter → Router → Handler is wired. Your server answers real requests.


Part 2 — Accept data

Task API Tutorial

Part 2 of 3 · Accept data

Part 2 of 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.body in a handler
  • Store state between requests
  • Reject bad input with a real 400

Pipeline this part builds

  1. Adapter
  2. NewMiddleware
  3. Router
  4. Handler
  5. Errors

Step 4 / 5 — Accept data with POST

Why now: reading a body is where the middleware box earns its place.

This step adds

  • ✓ In-memory Map as the store
  • app.use(json()) before the routes
  • POST /tasks + GET /tasks (replaces the Part 1 GET / demo route)
  const app = createApp();
+ app.use(json());

  const router = createRouter();
+ router.post('/tasks', (ctx) => { ... });
+ router.get('/tasks', (ctx) => { ... });
Full src/index.ts after this step
src/index.ts
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 above app.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.)


Part 3 — Read it back, honestly

Task API Tutorial

Part 3 of 3 · Read it back, honestly

Part 3 of 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 NotFoundError for a real 404

Pipeline this part completes

  1. Adapter
  2. Middleware
  3. Router
  4. Handler
  5. NewErrors

Step 5 / 5 — Read it back, or return a real 404

Why now: storing only matters if you can fetch — and a miss must not look like success.

This step adds

  • GET /tasks/:id
  • NotFoundError when 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);
+ });
Full finished src/index.ts
src/index.ts
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 Found

💡 Why this works

  • :idctx.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/999404; /tasks/abc → clean 404 (not a crash).

✅ Part 3 complete

The pipeline is real, running code

You now have

  • 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

Part 3 complete — create, read, and honest 404s. The full request pipeline is wired.

task-api/
  package.json
  src/
    index.ts

If 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:

Loading diagram...
  1. Adapter
  2. Middleware
  3. Router
  4. Handler
  5. Context
Full src/index.ts — compare against yours
src/index.ts
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.

What you built

  • ApplicationcreateApp(), owns the middleware pipeline
  • RoutercreateRouter(), mounted with app.route()
  • Store — in-memory Map across requests
  • Three routesGET /tasks, POST /tasks, GET /tasks/:id
  • Two error paths — real 400 and real 404

What you learned

Mental models (not the file list):

  • Request pipeline — Adapter → Middleware → Router → Handler → Context
  • Routing — params + mount — Routing
  • Contextctx.body, ctx.params, ctx.json(), ctx.statusContext
  • Middleware order — parser before router or ctx.body is 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.

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
  1. Router mountapp.route('/', router) keeps task routes in one module you can test and reuse. Mount at /api later without rewriting handlers.
  2. Middleware above routesjson() runs for every request before matching. Inside a handler is too late; after app.route(...) never runs for mounted routes.
  3. 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.

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.

⭐ Starter~5 min

Add DELETE /tasks/:id

Remove a task. Real 404 if missing. 204 on success.

⭐⭐ Intermediate~10 min

Add PUT /tasks/:id

Replace title and done. Keep the same id. 404 if missing.

⭐⭐⭐ Advanced~20 min

Move storage into a repository

Extract the Map behind TaskRepository with create / findById / delete. Handlers never touch the Map.

⭐⭐⭐⭐ Expert~45 min

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)
src/index.ts (added route)
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 Found
Solution — Intermediate (PUT)
src/index.ts (added route)
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:

src/task-repository.ts
export type 
type Task = {
    id: number;
    title: string;
    done: boolean;
}
Task
= { id: numberid: number; title: stringtitle: string; done: booleandone: boolean };
export function
function createTaskRepository(): {
    create(title: string): Task;
    findById(id: number): Task | undefined;
    findAll(): Task[];
    delete(id: number): boolean;
}
createTaskRepository
() {
const const tasks: Map<number, Task>tasks = new
var Map: MapConstructor
new <number, Task>(iterable?: Iterable<readonly [number, Task]> | null | undefined) => Map<number, Task> (+3 overloads)
Map
<number,
type Task = {
    id: number;
    title: string;
    done: boolean;
}
Task
>();
let let nextId: numbernextId = 1; return { function create(title: string): Taskcreate(title: stringtitle: string):
type Task = {
    id: number;
    title: string;
    done: boolean;
}
Task
{
const const task: Tasktask:
type Task = {
    id: number;
    title: string;
    done: boolean;
}
Task
= { id: numberid: let nextId: numbernextId++, title: stringtitle, done: booleandone: false };
const tasks: Map<number, Task>tasks.Map<number, Task>.set(key: number, value: Task): Map<number, Task>
Adds a new element with a specified key and value to the Map. If an element with the same key already exists, the element will be updated.
set
(const task: Tasktask.id: numberid, const task: Tasktask);
return const task: Tasktask; }, function findById(id: number): Task | undefinedfindById(id: numberid: number):
type Task = {
    id: number;
    title: string;
    done: boolean;
}
Task
| undefined {
return const tasks: Map<number, Task>tasks.Map<number, Task>.get(key: number): Task | undefined
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.
get
(id: numberid);
}, function findAll(): Task[]findAll():
type Task = {
    id: number;
    title: string;
    done: boolean;
}
Task
[] {
return [...const tasks: Map<number, Task>tasks.Map<number, Task>.values(): MapIterator<Task>
Returns an iterable of values in the map
values
()];
}, function delete(id: number): booleandelete(id: numberid: number): boolean { return const tasks: Map<number, Task>tasks.Map<number, Task>.delete(key: number): boolean
@returnstrue if an element in the Map existed and has been removed, or false if the element does not exist.
delete
(id: numberid);
}, }; } const
const repo: {
    create(title: string): Task;
    findById(id: number): Task | undefined;
    findAll(): Task[];
    delete(id: number): boolean;
}
repo
=
function createTaskRepository(): {
    create(title: string): Task;
    findById(id: number): Task | undefined;
    findAll(): Task[];
    delete(id: number): boolean;
}
createTaskRepository
();

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.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 }).

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(...)

Next tutorial

Continue learning

One recommended path, then depth when you need 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.

Was this helpful?

On this page