Core Concepts
Intelligent Routing
Declare what a route needs. Get the optimal order for free. Auth, validation, rate limits and caching execute in a security-first pipeline every time — so middleware ordering stops being something you can get wrong.
Overview
Middleware order is where the nastiest production bugs live. Put a rate limiter behind your auth check and an unauthenticated flood gets token verification done on its behalf. Put a cache in front of auth and you serve private data to strangers. Every framework hands you that footgun and wishes you luck.
MoroJS takes the decision away. Declare what a route needs — .auth(), .rateLimit(), .body(), .cache() — in whatever order reads best. The route compiles to one execution plan, ordered so the cheapest checks shed load first and the expensive ones never run for a request that was going to be rejected anyway. Chain order is style. Execution order is settled.
1import { createApp, z } from '@morojs/moro';
2
3const app = await createApp();
4
5// Simple route - types inferred automatically
6app.get('/users/:id', ({ params }) => {
7 // params.id is automatically typed as string
8 return { userId: params.id };
9});
10
11// With validation - types flow from the schema
12app.post('/users')
13 .body(z.object({
14 name: z.string(),
15 email: z.string().email()
16 }))
17 .handler((req) => {
18 // req.body is fully typed and validated
19 return { success: true, user: req.body };
20 });Traditional Frameworks
- You own the ordering — and the bugs
- A reordered app.use() can silently expose data
- Route matching resolved per request
- Type safety requires extra work
With MoroJS
- Ordering is decided for you, security-first
- Rate limits shed floods before auth ever runs
- Execution plan compiled once, at registration
- Type safety built-in
Compiled, Not Interpreted
Each route resolves to an execution plan once, at registration. Routes with nothing declared skip the pipeline entirely and take a compiled fast path.
Type Safe
Parameters, query strings, and bodies are automatically typed. No manual type definitions.
Secure By Order
Rate limiting runs before auth. Auth runs before the cache. The order that protects you is the only order there is.
How It Works
When you register a route, MoroJS resolves it into an execution plan and stores it. Everything you declared is slotted into the pipeline once, in the order that protects you — so per request there is no ordering logic, no dependency graph, and nothing to infer. Just a list to walk.
That predictability is the point. The plan is the same on request one and request ten million, identical across your whole codebase, and knowable without running anything. Declare nothing and the route skips the pipeline entirely on a compiled fast path; types come from your route patterns and schemas either way.
1import { createApp } from '@morojs/moro';
2
3const app = await createApp();
4
5// Simple GET route
6app.get('/', () => {
7 return { message: 'Hello World' };
8});
9
10// Route with parameters
11app.get('/users/:id', ({ params }) => {
12 // params.id is automatically typed as string
13 return { userId: params.id };
14});
15
16// Multiple HTTP methods
17app.post('/users', ({ body }) => {
18 return { success: true };
19});
20
21app.put('/users/:id', ({ params, body }) => {
22 return { userId: params.id, updated: true };
23});1import { createApp } from '@morojs/moro';
2import { z } from 'zod';
3
4const app = await createApp();
5
6const CreateUserSchema = z.object({
7 name: z.string().min(1).max(100),
8 email: z.string().email(),
9 age: z.number().min(18).max(120)
10});
11
12// Chainable API style - Recommended
13app.post('/users')
14 .body(CreateUserSchema)
15 .auth({ roles: ['admin'] })
16 .rateLimit({ requests: 10, window: 60000 })
17 .handler(async (req) => {
18 // req.body is now fully typed and validated.
19 // Execution order is fixed: rate limit -> auth -> validation,
20 // no matter how the chain above is written.
21 const user = await createUser(req.body);
22 return { success: true, user };
23 });Route Patterns
MoroJS supports a variety of route patterns, from simple static routes to complex parameterized paths.
1// Static routes
2app.get('/api/health').handler(() => ({ status: 'ok' }));
3
4// Named parameters
5app.get('/users/:id').handler((req) => ({ userId: req.params.id }));
6app.get('/users/:id/posts/:postId').handler((req) => ({ ...req.params }));
7
8// Optional parameters
9app.get('/posts/:id?').handler((req) => ({ postId: req.params.id }));
10
11// Wildcard routes
12app.get('/files/*').handler((req) => ({ path: req.params['*'] }));
13
14// Query parameters (automatically parsed)
15app.get('/search')
16 .query(z.object({
17 q: z.string(),
18 limit: z.coerce.number().default(10)
19 }))
20 .handler((req) => {
21 return { results: search(req.query.q, req.query.limit) };
22 });
23
24// Route groups with common prefix
25app.group('/api/v1', (group) => {
26 group.get('/users').handler(getUsersHandler);
27 group.post('/users').handler(createUserHandler);
28 group.get('/users/:id').handler(getUserHandler);
29});The Intelligent Middleware Pipeline
The order is chosen so the cheapest checks shed load first. Rate limiting goes before everything: an over-limit client is turned away on an IP + route key without a single token being verified or a byte of schema being parsed on its behalf. Then auth, then validation, then the cache lookup — which sits behind auth on purpose, so a cached response can never reach someone who was not allowed to ask for it.
Chain them in whatever order reads best. Execution never changes.
1app.get('/protected-data')
2 .cache({ ttl: 300 }) // seconds
3 .auth({ roles: ['user'] })
4 .rateLimit({ requests: 100, window: 3600000 })
5 .handler(async (req) => {
6 // Chained as cache -> auth -> rateLimit, but execution is
7 // always: rate limit -> auth -> cache check -> handler.
8 // The cache sits behind auth, so cached responses stay auth-gated.
9 return await getProtectedData(req.auth.user);
10 });Custom middleware is placed by you, not analyzed. Your functions are opaque to the framework — it cannot know that one is "auth" and another is "logging." Instead of guessing, MoroJS gives you explicit phase slots: .before() runs ahead of the pipeline, .transform() runs after validation, and .after() runs last, just before the handler. .use() is shorthand for .after(). Within a phase, your middleware runs in declaration order.
1app.get('/report')
2 .auth({ roles: ['analyst'] })
3 .before((req, res) => {
4 // Runs ahead of rate limiting and auth
5 })
6 .transform((req, res) => {
7 // Runs after auth + validation, before the cache check
8 })
9 .use((req, res) => {
10 // .use() === .after(): runs right before the handler,
11 // in declaration order. Note: a cache hit responds before
12 // this phase - put must-always-run logic in .before()
13 })
14 .handler(req => getReport(req.auth.user));Execution Order (fixed)
- 1Request parsing
- 2.before() middleware
- 3Rate limiting (IP + route key)
- 4Authentication (.auth)
- 5Validation (.body / .query / .params / .headers)
- 6.transform() middleware
- 7Cache check (.cache)
- 8.use() / .after() middleware
- 9Handler execution
Why This Order
- Rate limiting sheds floods before auth or parsing work
- Cache sits behind auth - cached data stays protected
- Early exit at every phase on failure
- Routes with no features skip the pipeline entirely
- Declaration order within each custom phase - no surprises
Route Compilation & Performance
At application startup, MoroJS analyzes all your routes and creates an optimized execution plan. Routes are compiled, middleware chains are optimized, and type-safe handlers are generated.
Route Compilation Process
- Builds optimal middleware chains
- Pre-compiles route matchers
- Generates type-safe handlers
- Optimizes for common patterns
1// Development: Route definition
2app.get('/users/:id')
3 .params(z.object({ id: z.string().uuid() }))
4 .handler((req) => getUserById(req.params.id));
5
6// Runtime: Compiles to optimized function
7const compiledRoute = {
8 method: 'GET',
9 pattern: /^\/users\/([0-9a-f-]{36})$/,
10 paramNames: ['id'],
11 middlewareChain: [
12 validateParams,
13 executeHandler
14 ],
15 handler: (ctx) => {
16 // Pre-validated, type-safe execution
17 return getUserById(ctx.params.id);
18 }
19};Best Practices
Do
- Use descriptive route patterns
- Define validation schemas
- Group related routes together
- Leverage automatic middleware ordering
- Use type-safe parameter extraction
- Define response schemas for documentation
Don't
- Manually order middleware unnecessarily
- Skip input validation
- Use overly complex route patterns
- Ignore TypeScript warnings
- Mix business logic in middleware
- Forget to handle errors properly