Router
The routes can be created in two ways; through the main server(), or through the router() (from import { router } from '@server/next';).
For simple applications just using the server() instance is usually enough and recommended. But once you start to have too many routes (a good rule of thumb is 10 routes), it's convenient to split into different files with router().
Let's see first a simple example of how to define different routes:
import server from '@server/next';
export default server()
.get('/', () => 'Hello from the homepage')
.get('/info', () => '<p>Greetings from the info page</p>')
.post('/contact', ctx => {
console.log(ctx.body);
return 'Message sent successfully';
})
.put('/users/:id', ctx => {
console.log(ctx.url.params.id, ctx.body);
return 200;
});A route is composed of the method, the path, an optional options object, and the middleware:
.METHOD(PATH, OPTIONS?, ...MIDDLEWARE)The method can be any of the well-known HTTP methods:
get(): Used to request data from a specified resource. Does not accept a body.post(): Used to send data to a server to create/update a resource. Accepts a body.put(): Used to update a current resource with new data. Accepts a body.patch(): Used to apply partial modifications to a resource. Accepts a body.delete(): Used to delete a specified resource. Does not usually accept a body.options(): Used to describe the communication options for the target resource. Does not accept a body.head(): Similar to GET, but it requests a response without the body content. Does not accept a body.
The path is detailed in the path documentation, but generally is one of these: omitted, an exact match, a parameter-based path, or a wildcard path:
- omitted: it will match any path, in the router it is the same as
* /info: exact match tolocalhost:3000/info/posts/:id: a match by parameter/posts/:id(number): a match by parameter, including types/posts/*: a match to any sub-path that has not been previously matched
Finally, the middleware is a function that gets called when both the method and paths match the current request. There are two types, one is a simple middleware that does not return anything; no return, return undefined, return null or return false all work like that. These will be called in sequence as detailed below, until finding a middleware that returns something or there is no middleware left.
export default server()
.use(middleware1)
.get('/', middleware2, middleware3, middleware4);In this example, opening http://localhost:3000/ calls middleware1, middleware2, middleware3, then middleware4 in order, stopping as soon as one of them returns a response.
If in that example middleware 4 returns something, that will be used to respond to the browser. If it does not, a 404 will be triggered, since once a method is matched it will not continue to other methods.
Route options
An options object can go between the path and the middleware to override the server defaults for that route only (local wins):
server({ cache: false })
// cache this route for an hour
.get('/posts', { cache: '1h' }, () => Post.list())
// read the raw bytes for a webhook signature
.post('/hook', { body: 'raw' }, (ctx) => verify(ctx.body));| Option | Type | Description |
|---|---|---|
body | 'parse' | 'raw' | 'stream' or { mode, max } | How the request body is read, see body |
cache | duration | number | false | Cache-Control for this route, see cache |
tags | string | string[] | Group the route (used by OpenAPI) |
title | string | Short label for the route (OpenAPI) |
description | string | Longer description for the route (OpenAPI) |
Global middleware with .use()
.use() registers cross-cutting middleware that run on every request, before the matched route. It accepts either a middleware function or a whole router(). It does not take a path or an options object; those belong on the route methods.
export default server()
// a middleware: runs for every request
.use(logger)
.use(otherRouter) // merge another router's routes in
.get('/', () => 'Hello');Scoping middleware to some paths
Because .use() is global, when a middleware should apply only to some paths you have three options:
1. Check the path inside the middleware:
const requireAdmin = (ctx) => {
// not our concern, skip
if (!ctx.url.pathname.startsWith('/admin/')) return;
if (!ctx.user) return 401;
};
export default server()
.use(requireAdmin)
.get('/admin/settings', settingsReply);2. Put the middleware on a router, where it applies to that router's routes only:
const admin = router()
.use(requireAuth)
.get('/admin/settings', settingsReply)
.get('/admin/users', usersReply);
export default server().use(admin);3. Repeat the middleware on each route:
export default server()
.get('/admin/settings', requireAuth, settingsReply)
.get('/admin/users', requireAuth, usersReply);For express users
There are 2 key differences between express.js and server.js, with the goal of making it very obvious what paths are being matched by requests:
- Paths do not match subpaths by default. This means that if you write
.get('/')and the browser calls/info, that path will not get matched. If you want that funcionality, please be more explicit and write.get('/*'). - Only one HTTP method match for server.js. Once a method is matched, no other method can be matched. This means that if you write
.get('/*')and then.get('/info'), that one can never be matched since all the paths go into the first one. For this, please write the.get('/info')paths first.
One consequence: prefix layering like .get('/admin/*', authMiddleware).get('/admin/settings', …) won't work, because once /admin/* matches, /admin/settings is never reached. See Scoping middleware to some paths for the patterns to use instead.
Parameters
The route parameters are represented in the path as /:name and then are passed as a string to ctx.url.params.name (see url docs as well):
export default server()
.get('/:page', ctx => ({
page: ctx.url.params.page,
name: ctx.url.query.name
}));
// test.js
const res = await app.test().get('/hello?name=Francisco');
const { body } = res;
expect(body).toEqual({ page: 'hello', name: 'Francisco' });Parameters can also have types explicitly set, in which case they will be casted to that type. There's only 3 possible types, string (default), number and date:
// When requesting `/users/25
export default server().get('/users/:id(number)', ctx => {
console.log(
ctx.url.pathname,
ctx.url.params.id,
typeof ctx.url.params.id
);
// /users/25 25 number
});
// When requesting `/calendar/2025-01-01`
export default server().get('/calendar/:day(date)', ctx => {
const day = ctx.url.params.day;
console.log(day, day instanceof Date);
// Date('2025-01-01) true
});Wildcards
By default path matches are exact (unlike express.js):
import server, { file } from '@server/next';
export default server()
.get('/', () => file('./home.html'))
.get('/info', () => file('./info.html'));To match any sub-path, use *:
export default server()
.get('/files/*', (ctx) => {
return `Requested: ${ctx.url.pathname}`;
});Using router()
For larger applications, split your routes into separate files using router(). A router defines routes with their full paths and is merged in with .use():
// routes/users.js
import { router } from '@server/next';
export default router()
.get('/users', () => User.list())
.get('/users/:id(number)',
(ctx) => User.find(ctx.url.params.id))
.post('/users', (ctx) => User.create(ctx.body))
.put('/users/:id(number)',
(ctx) => User.update(ctx.url.params.id, ctx.body))
.delete('/users/:id(number)',
(ctx) => User.delete(ctx.url.params.id));// index.js
import server from '@server/next';
import usersRouter from './routes/users.js';
export default server().use(usersRouter);Routers are merged at the root, so write the full path on each route.