NextRush in One Page
The whole framework as one mental map — why it exists, its philosophy, the request path, every core piece, both programming styles, the runtimes, and where each idea lives in depth.
The framework map
NextRush is a Node.js HTTP framework built around one idea: you should be able to read the code and know exactly what happens on every request — no hidden middleware, no auto-wired magic, no runtime you didn't ask for.
This is the whole framework on one page, told in five acts: Why, How, Building, Running, and Learning. Every chapter follows the same loop — the idea, the picture, code you can run, and what to remember — so by the end you'll recognize 70–80% of NextRush's core ideas and know exactly which page to open next for depth.
The one line every request walks — the recurring motif you'll see broken down below:
- Request
- Application
- Middleware
- Router
- Handler
- Response
What you'll walk away with
A picture you can redraw on a whiteboard, working code for every idea, and a map of where each piece lives in depth. This page teaches the shape of the framework — each chapter links to its concept page for the full detail. It's the map; the concept pages are the territory.
Five acts, in order — Why it exists, How it works, what you'd spend most of your time on Building, where it ends up Running, and Learning where to go next:
- Why
- How
- Building
- Running
- Learning
Act 1 · Why
Before any piece of the framework, the reasoning behind all of them: what building an API without NextRush actually feels like, and the four commitments NextRush makes instead.
Why NextRush exists
Picture the last time you started a plain Express API. Routes are wired by hand, request bodies come
in untyped until you add and configure a parser, and nothing stops a req.user from being undefined
at 2am in production. Reach for Fastify and the throughput problem is solved, but you're Node-only and
writing JSON-Schema by hand for every route. Reach for NestJS and the structure problem is solved, but
now you're learning decorators, modules, and an Angular-shaped runtime before your first GET even
exists.
Every one of those frameworks is good at what it optimizes for. The problem is that each one asks you to accept a real cost to get there — an ecosystem you wire by hand, a runtime you're locked into, or a framework you have to learn before you can use it. NextRush is what happens when none of those costs are treated as the price of entry. Strict types from the first route, structure that's optional instead of mandatory, a core small enough to read start to finish in one sitting, and the same application code running on four different runtimes.
That's the shape of the bet this framework makes. The head-to-head comparison against Express, Fastify, Koa, NestJS, and Hono lives on the Introduction; the rest of this page is about how NextRush itself thinks once you're inside it.
The philosophy
Four commitments explain nearly every design decision in the framework. Hold these and the rest follows:
Small core
The functional core is a few thousand lines you can read end to end. Less framework to learn, less to hide a surprise in.
Explicit wiring
Nothing runs that you didn't add. Middleware, routes, and extensions are wired by you, so reading the code tells you the whole behavior.
Capability composition
One package per capability. You install validation, CORS, or WebSocket when you need them — the base stays small.
Runtime independence
The request path speaks web standards, so the same app runs on Node, Bun, Deno, and the edge behind an adapter.
Remember
NextRush trades nothing to get small, typed, and portable: a small core you can finish reading, wiring you can see in the code, capability you add one package at a time, and one codebase across runtimes. Everything from here on is those four ideas made concrete.
- NextRush sits in the gap Express (untyped) and NestJS (heavy) leave open — with strict types and optional structure
- Four commitments explain every design decision: small core, explicit wiring, capability composition, runtime independence
- If you accept any framework's cost (ecosystem, runtime lock-in, learning curve) as the price of entry — you don't need NextRush
- All four ideas are made concrete by the four nodes on the request flow, which is what the next act walks through
Act 2 · How
The philosophy explains why. This act shows how — one request, traced through every piece it touches, starting with the single diagram the rest of the page keeps pointing back to.
The framework on one diagram
This is the picture to redraw from memory. Every chapter after it explains one node on it. A request enters the Application, flows down the Middleware pipeline, the Router picks a handler — written either as a plain function or as a class controller — and both read from and write to the same Context before a Response goes back out.
The two handler shapes converge on one Context and one Response — that convergence is the point: functional and class are two ways to write the same pipeline, not two frameworks.
Application
A request arrives at one place first — the Application owns that place. Every later node on the flow (Application → Middleware → Router → Context) is something it holds or hands the request down to.
The first node after a request arrives — the entry point and orchestrator.
Why a separate piece at all? Because something has to own the pipeline before any request logic runs — a place to register middleware and mount routers once, instead of re-wiring them on every request. That's all the Application is: the entry point and orchestrator. It doesn't match routes or handle requests itself — it owns the pieces that do and hands each request down the chain.
Run this and it answers a request — nothing else has been added yet:
import { createApp, createRouter, listen } from 'nextrush';
const app = createApp(); // owns the chain; nothing runs until you add to it
const router = createRouter();
router.get('/', (ctx) => ctx.json({ status: 'ok' }));
app.route('/', router);
await listen(app, 8080); // curl localhost:8080/ → {"status":"ok"}| The Application owns | What that means |
|---|---|
| The middleware chain | The ordered list every request passes through |
| Mounted routers | Where routes attach (app.route('/', router)) |
| Extensions | Long-lived, app-scoped services booted at app.ready() |
| The lifecycle | Startup, readiness, and graceful shutdown |
Common mistake
Calling createApp() more than once per process. One app owns one middleware chain — if you find
yourself creating a second Application, you probably want a second router mounted at a different
prefix instead.
Why is createApp() a function and not a class you extend?
Extending a class ties your app to whatever that class's constructor does — you inherit its
internals, not only its behavior. A factory function returns a plain object with a known, closed
surface: you compose behavior by calling app.use()/app.route(), never by overriding a method the
framework calls internally. That keeps "reading the code tells you the whole behavior" true even for
the Application itself.
Full detail: Application.
Middleware
The Application owns the pipeline; the pipeline still needs behavior that runs for every request regardless of path — that behavior goes in Middleware, the second node on the flow.
The pipeline a request flows through before the Router — auth, parsing, logging, all live here.
Why not put auth and logging straight in the handler? Because then every handler re-implements
them, and a change to how you log means editing every route. Middleware wraps that behavior around
a handler like layers of an onion — one place for auth, body parsing, and logging — instead of one
copy per route. Each layer runs before the handler, calls ctx.next(), and then runs again after
on the way back out. The rule that trips people up: order of registration is order of execution.
A middleware you can register right now — timing every request:
app.use(async (ctx) => {
const start = Date.now();
await ctx.next(); // before this line: on the way in. after it: on the way out.
console.log(`${ctx.method} ${ctx.path} — ${Date.now() - start}ms`);
});Common mistake
Registering a body parser after the router. ctx.body stays empty in every handler, because
middleware runs in registration order and the parser never got a chance to run first.
What happens if a middleware never calls ctx.next()?
The chain stops there — nothing downstream of it runs, including the router and the handler. That's
not a bug to guard against; it's how you short-circuit a request on purpose (an auth check that
responds 401 and returns, without calling next(), is exactly this).
Full detail: Middleware.
Router
Middleware ran for every request; the next job is to pick the ONE handler responsible for this path — that's the Router's job, the third node on the flow and where the pipeline hands off to your code.
Where the pipeline hands off to your code — maps a path to the one handler responsible for it.
Why not check the URL yourself in one big handler? Because that turns into an unmaintainable
chain of if statements the moment you have more than a few routes. The Router maps a request's
path to the one handler responsible for it. It's backed by a segment trie, so lookup cost tracks
how many segments a URL has, not how many routes you've registered. It captures parameters
(/users/:id) and supports grouping and mounting sub-routers under a prefix.
That tree is the structure a request walks segment by segment. Here it is as real routes:
const router = createRouter();
router.get('/users/:id', (ctx) => ctx.json({ id: ctx.params.id }));
router.get('/tasks/:id', (ctx) => ctx.json({ id: ctx.params.id }));
// GET /users/42 → { "id": "42" } — :id is captured as a stringCommon mistake
Comparing ctx.params.id to a number with ===. Route params are always strings — ctx.params.id === 42 is false even when the URL is /users/42. Convert first: Number(ctx.params.id) === 42.
Check yourself
Which piece decides which handler runs for a given path — Middleware or Router?
Show answer
The Router. Middleware runs for every request that reaches it regardless of path; the Router is the one piece that looks at the path and picks a single handler for it.
Full detail: Router.
Context
Application, Middleware, and the Router's handler all need to read the same per-request shape and write back to the same response — that's the fourth and final concept node, Context.
What every node above actually reads from and writes to — one fresh object per request.
Why one object instead of the raw request and response? Because juggling req and res
separately means remembering two APIs, two mutation points, and — across runtimes — two different
underlying types. Context (ctx) is the one per-request object every step reads from and writes
to instead. You never touch the raw req/res directly — ctx carries the request in and the
response out, and it's fresh per request so nothing leaks between concurrent ones.
router.post('/tasks', (ctx) => {
const body = ctx.body as { title: string }; // in: parsed by a body-parser middleware
ctx.status = 201; // out: status
ctx.json({ id: 1, title: body.title }); // out: JSON response
});| Reads input | Writes output | Shares state |
|---|---|---|
ctx.params, ctx.query | ctx.json(...), ctx.send(...) | ctx.state between middleware |
ctx.body, ctx.headers | ctx.status, ctx.set(...) | ctx.next() to continue the chain |
Common mistake
Reading ctx.body in a route with no body-parser middleware registered. It stays undefined —
Context doesn't parse bodies itself; a middleware like @nextrush/body-parser's json() does that
and puts the result on ctx.body.
Why does ctx.state exist if middleware can mutate ctx directly anyway?
You can attach anything to ctx directly, but ctx.state is the one place every middleware author
agrees to look — a shared namespace, not a free-for-all on the object's other properties. It's a
convention that keeps two unrelated middleware from ever colliding on the same property name.
Full detail: Context.
Remember
One request walks four nodes in a fixed order: the Application owns the chain, Middleware wraps the handler before and after, the Router picks which handler runs, and Context is the one object all of them read from and write to. That's the diagram above — nothing more.
- Why the Application exists as a separate piece from the router — it owns the chain, route handlers do not
- Why middleware order is execution order, and what skipping `ctx.next()` does (intentional short-circuit)
- Why the router is backed by a segment trie instead of a list of `if` statements — lookup cost tracks URL depth
- Why `ctx` replaces raw `req`/`res`, and what `ctx.state` is for — a shared namespace every middleware agrees on
Act 3 · Building
You now know how one request flows. This act is about writing the code that handles it: what you install, which syntax you write it in, and how to add capability beyond a single request.
Packages
Why does this come before you've even written a route? Because it's one of NextRush's core
differences from a batteries-included framework, and it shapes every decision after this point: you
never install more than the request you're building actually needs. The nextrush meta-package
gives you the core — createApp, createRouter, listen, the Context API, and the error classes. It
does not bundle validation, CORS, security headers, or WebSocket support. You add each capability as
its own package, so your install stays as small as your app.
import { createApp, listen } from 'nextrush';
import { cors } from '@nextrush/cors'; // added because this API is called cross-origin
import { json } from '@nextrush/body-parser'; // added because a route reads a JSON body
const app = createApp();
app.use(cors());
app.use(json());
await listen(app, 8080);Decide what to install by what the request needs, not by habit:
| You need | Install | Then |
|---|---|---|
| JSON / form bodies | @nextrush/body-parser | app.use(json()) |
| Cross-origin requests | @nextrush/cors | app.use(cors()) |
| Security headers | @nextrush/helmet | app.use(helmet()) |
| Request validation | @nextrush/validation | validate against a schema |
| WebSocket | @nextrush/websocket | attach the WebSocket handler |
Common mistake
Installing @nextrush/cors or @nextrush/helmet in a project that never actually needs cross-origin
requests or those headers "in case it's needed later." Every package you add is code that ships and a decision
someone has to understand later — install the ones the request in front of you actually needs.
Why is DI the one exception to "zero external dependencies"?
The functional core has no external runtime dependencies at all — createApp, createRouter, and
listen are built entirely on internal @nextrush/* packages. nextrush/class is the one exception:
its dependency injection container is built on reflect-metadata and tsyringe, two real external
packages. Stay on the functional path and neither one touches your node_modules; import
nextrush/class and they come with it. That's a deliberate trade, not an oversight — building a DI
container from scratch would mean maintaining decorator metadata handling in-house for a feature only
the class runtime needs.
Full detail: packages reference.
Two programming styles
Why does the framework support two ways to write the same thing? Because "small service" and "large team" want different amounts of structure, and forcing one style onto both means either over-engineering the small service or under-structuring the large one. NextRush gives you two ways to write the same pipeline, and you can mix them in one project. Both compile to identical routing and middleware — same engine, different syntax, no performance trade-off.
Plain functions and createApp() / createRouter() — the fewest moving parts.
import { createApp, createRouter, listen } from 'nextrush';
const app = createApp();
const router = createRouter();
router.get('/users/:id', (ctx) => ctx.json({ id: ctx.params.id }));
app.route('/', router);
await listen(app, 8080);@Controller classes with dependency injection, from nextrush/class — structure for larger apps.
import { Controller, Get, Param } from 'nextrush/class';
@Controller('/users')
class UserController {
@Get('/:id')
findOne(@Param('id') id: string) {
return { id };
}
}Common mistake
Picking class-based for a two-route script because it "looks more professional." Class controllers add real ceremony — decorators, DI registration, a container — that pays off with guards and constructor-injected services at team scale, not on a handful of routes.
Full detail: Application (functional) and dependency injection (class-based).
Extensions
You've seen Middleware, Routes, and Context — all per-request. Some capabilities don't run per request at all (a DB pool, an event bus) and live for the app's whole lifetime instead.
Capability that lives beyond a single request — app-scoped, booted once, torn down on shutdown.
Why isn't everything middleware, if middleware handles 99% of capability? Because some things aren't per-request at all — a database connection pool or an event bus needs to boot once and live for the app's lifetime, not run on every request. NextRush gives you three mechanisms, and which one you reach for is a decision, not a guess:
| Need | Reach for | Because |
|---|---|---|
| Auth, logging, parsing — runs on every request | Middleware (app.use(...)) | It's per-request behavior — ~99% of capability is this |
| Wiring a subsystem once at startup | Registrar (a plain function, e.g. registerControllers) | It runs once, not per request, but needs no lifetime after that |
| A stateful service that lives for the app's whole lifetime | Extension (app.extend(...), booted at app.ready()) | It needs boot and teardown — an event bus, a connection pool |
import { events } from '@nextrush/events';
const app = createApp().extend(events()); // boots once, lives for the app's lifetime
await app.ready();Common mistake
Reaching for an Extension when a Registrar would do. If your code runs once at startup and needs no
teardown, it's a registrar — wrapping it in app.extend() for a lifecycle it doesn't need is the
~0.1%-mechanism doing a ~0.9%-mechanism's job.
This is capability composition in practice: the base stays small, and you add exactly the mechanism a feature needs. Full detail: Extensions.
Errors
Everything so far assumed a handler finished normally. Errors are the escape hatch — what runs when one cannot.
What happens when a handler can't finish normally — throw, get mapped, surface as clean status + JSON.
Why throw instead of returning an error object? Because a thrown error can't be silently ignored the way a returned value can — forgetting to check a return value is a real, common bug; forgetting to catch a throw is not, since the framework catches it for you either way. You throw a typed error class, and the framework maps it to the right status and JSON body. You describe what went wrong, not how to format it — and internal stack traces never leak in production.
import { NotFoundError } from 'nextrush';
router.get('/tasks/:id', (ctx) => {
const task = tasks.get(Number(ctx.params.id));
if (!task) throw new NotFoundError('Task not found'); // → 404, formatted for you
ctx.json(task);
});Common mistake
Returning ctx.json(null) with a 200 for a missing resource instead of throwing. A client can't
tell "found, and it's empty" apart from "not found" — throw NotFoundError so the status carries the
meaning.
- Why packages are per-capability, and what installing for "later, maybe" actually costs
- Why functional and class are the same engine with different syntax, and when each fits (small vs team-scale)
- The decision — middleware, registrar, or extension — and why each exists (per-request, once, app-lifetime)
- Why thrown errors beat returned error objects — a forgotten throw is caught for you, a forgotten return is not
Act 4 · Running
You've built something. This act is about where it actually runs — the runtimes NextRush targets, the seam that makes that possible, and what the server does across its whole lifetime, not only one request.
One app, every runtime
Why should switching runtimes cost this little? Because the request path speaks only web-standard
Request/Response, so your application code — routes, middleware, controllers — doesn't change
when you move it. Only the entry adapter does.
Node.js
The baseline every other runtime is measured against.
Bun
Runs on Bun.serve().
Deno
Runs on Deno.serve(), with its permission model.
Edge
Cloudflare Workers and Vercel Edge — no long-running server.
Serverless
AWS Lambda, Google Cloud Functions, Azure Functions.
Full detail: runtime compatibility.
Adapters
Why doesn't the framework target Node only and call it done? Because the moment it hard-codes
Node's APIs anywhere in the request path, "one app, every runtime" stops being true. An adapter is
the bridge between your application and a runtime's native server — the same app from every chapter
above, unchanged, handed to a different listen-equivalent per runtime.
// The same app — only the adapter import changes per runtime
import { createApp, createRouter } from 'nextrush';
import { listen } from '@nextrush/adapter-node'; // swap for @nextrush/adapter-bun, etc.
const app = createApp();
// ...routes, middleware — identical on every runtime
await listen(app, 8080);Common mistake
Importing node:fs, process, or another Node-only API directly inside a route handler or
middleware. It works on Node and silently breaks the moment that same code runs on the edge — runtime
APIs belong behind the adapter, never inline in application code.
That single adapter seam — the only layer that changes — is what makes "one app, every runtime" true rather than aspirational. The runtime target is any of the five above (Node, Bun, Deno, Edge, or Serverless).
The request lifecycle
Why look at the whole server instead of one request? Because a request handled correctly can still be dropped if the server exits mid-response — correctness at the request level isn't enough without correctness at the process level too. The Application starts, becomes ready, handles requests, and — on a shutdown signal — drains in-flight work before exiting. Readiness and draining are what let it deploy behind an orchestrator without dropping requests.
import { createApp } from 'nextrush';
import { serve } from '@nextrush/adapter-node';
const app = createApp();
await serve(app, { port: 8080, gracefulShutdown: true }); // drains on SIGTERM/SIGINT before exitingCommon mistake
Deploying behind an orchestrator (Kubernetes, ECS) without graceful shutdown enabled. The platform
sends SIGTERM and expects in-flight requests to finish before the process exits — without draining,
those requests get cut off mid-response during every rolling deploy.
Full detail: request lifecycle.
- Why the same application code runs on five different runtimes — only the adapter import changes
- What the adapter seam is, and why Node-only APIs (`node:fs`, `process`) never belong in application code
- The four states a server moves through (Starting → Ready → Handling → Draining), and why draining on SIGTERM matters for zero-drop deploys
Act 5 · Learning
You have the map. This last act is about what it's actually for — the services it's built to produce, and exactly where to go to turn this understanding into a running application.
What you can build
The pieces above combine into real services. A few shapes NextRush is built for:
REST APIs
Typed params, query, and body from one Context — either style.
Streaming & SSE
Server-Sent Events and NDJSON responses, built for AI and agentic apps.
Real-time servers
WebSocket connections alongside your HTTP routes.
Edge & serverless APIs
The same handlers on Workers, Vercel Edge, or Lambda.
Microservices
A small core keeps each service's footprint honest.
Class-based apps
Guards, interceptors, and request-scoped DI for larger teams.
Your learning path
You have the map. Here's the order the rest of the docs are meant to be read in — each stage assumes the one before it:
Build a Task API
The hands-on tutorial — POST a task, read it back, return a real 404.
Core Concepts
Every node on the map, taught in full: Application, Middleware, Router, Context, and more.
Install NextRush
Scaffold a project or install by hand, then verify a running server.
Reference
Exact signatures and options, grouped by capability, once you know what you're after.
One last picture before you go
You started with this line:
- Request
- Application
- Middleware
- Router
- Handler
- Response
That's the whole framework. Everything in the five acts above is one node on it, one runtime for it, or one detail about how it's wired. Read the code and you know what happens on every request — no hidden middleware, no auto-wired magic, no runtime you didn't ask for. Start with the Task API tutorial to make that line true for your first app, then keep this diagram in mind as a map the rest of the docs fill in.