Router
elements man router Read as markdownRouting in Elements maps each HTTP URL to a function handler that responds to
requests at that url. Routes are declared explicitly with app.route() in the
project's root index.ts. The app/pages/ folder is a convention for storing
page templates and route logic, and folder names do not affect routing at all.
The router works equally well for serving HTML pages and serving API endpoints.
URL patterns are full URLPattern expressions, the web standard for URL pattern
matching. Patterns support :name segments, * wildcards, optional groups, and
inline regex like :id(\d+). The second argument to app.route() is either a
handler function or a child router.
Routes are the entry point for HTML pages. A typical route handler will gather the data the page needs and return a new HTML template whose parameters are provided by the route function. Elements server-renders the HTML, sends the page along with any live tables, channels, and other reactive values it carries over the wire, and attaches it in the browser.
Route handlers have the same data features as @rpc functions. The session
global is available with the current user. Return an HTML template and Elements
server-renders it. Return an object or array and Elements serializes it through
the extended JSON serializer, preserving class identity, handling object cycles,
and serializing live tables. Return a string and it goes out as text/plain.
Write to res directly and return nothing.
app.use() registers a handler that runs before any route matches. Child
routers can define their own use() handlers. This is where cross-cutting
concerns like logging, custom headers, and feature flags go. Body parsing,
sessions, asset serving, and form handling are built in.
app.route() and app.use() register against the app's default router. Larger
apps create child routers with new Router() and mount them at a path prefix
like /admin/*. Routers nest as deep as needed.
Routing runs on the server, and the browser attaches to whatever the route renders.
At a Glance
index.tsimport { App } from "@elements/app"; import config from "#config"; import home from "#app/pages/home"; import roomsShow from "#app/pages/rooms-show"; import upload from "#app/pages/upload"; import api from "#app/api"; const app = new App(); app.use((req, res, next) => { console.log(`${req.method} ${req.url}`); next(); }); app.route("/", home); app.route("/rooms/:id", roomsShow); app.route({ method: "post", path: "/upload", handler: upload, }); app.route("/api/*", api); app.error((req, res, err) => { res.status(err.statusCode ?? 500).send(`error: ${err.message}`); }); app.start(config);
Handlers
A handler is a function or an object with a handle() method.
Type signaturetype Handler = HandlerFunction | HandlerObject; type HandlerFunction = (req: Request, res: Response) => HandlerResult | Promise<HandlerResult>; type HandlerObject = { handle(req: Request, res: Response): HandlerResult | Promise<HandlerResult>; mount?: boolean; };
A page's index.ts exports a route handler as the default export:
app/pages/home/index.tsimport { Request, Response } from "@elements/app"; import html from "./template"; export default function route(req: Request, res: Response) { let users = sql<User>(`select * from users`).all(); return new html({ users }); }
Return Values
Whatever a handler returns becomes the response.
- A
Sendable(for example, an HTML template instance) is sent with ETag and gzip. - A
stringis sent astext/plain. - An object or array is serialized as Elements JSON and sent.
voidorundefinedmeans the handler usedresdirectly.
index.tsapp.route("/api/rooms", () => sql(` select id, name from rooms order by createdAt desc `).all()); app.route("/health", (req, res) => "ok"); app.route("/redirect", () => { redirect("/"); });
URL Patterns
Routes are URLPattern expressions. Dynamic segments use :name, wildcards use
*, optional groups use ?, and inline regex goes inside parentheses.
index.tsapp.route("/", home); app.route("/rooms/:id", roomsShow); app.route("/posts/:id/comments/:commentId", showComment); app.route("/files/*", fileServer); app.route("/users/:id(\\d+)", showUser); app.route("/archive/:year/:month?", archive);
Match parameters land in req.params:
Exampleimport { Request, Response } from "@elements/app"; function route(req: Request, res: Response) { let id = req.params.id; let cid = req.params.commentId; }
Folder names under app/pages/ do not affect routing. The route URL is whatever
you pass to app.route().
Method Matching
A route matches any method by default.
index.tsapp.route("/api/users", listUsers); // any method app.route({ // POST only method: "post", path: "/api/users", handler: createUser, }); app.route({ // GET or POST method: ["get", "post"], path: "/api/data", handler: data, });
Domain Matching
Restrict a route to a specific hostname.
index.tsapp.route({ domain: "api.example.com", path: "/v1/*", handler: apiRouter, });
use() Handlers
app.use() registers a handler that runs before any route on the router
matches. Call next() to continue, send a response to stop the chain, or throw
to trigger the error handler. Handlers run in registration order.
index.tsapp.use((req, res, next) => { req.startTime = Date.now(); next(); });
Each router has its own use(). A handler registered on a child router only
runs for that router's routes.
index.tsimport { Router } from "@elements/app"; const api = new Router(); api.use((req, res, next) => { res.setHeader("Cache-Control", "no-store"); next(); }); api.route("/users", listUsers); app.route("/api/*", api);
Body parsing, session loading, asset serving, and form handling are built in.
Use use() for your own cross-cutting concerns: request logging, custom
headers, feature flags.
Sub-Routers
Nested routers mount at a path prefix.
index.tsimport { Router } from "@elements/app"; const api = new Router(); api.route("/users", listUsers); api.route("/users/:id", getUser); app.route("/api/*", api);
Inside api, the URL has the /api prefix stripped (/users, not
/api/users). The Router class has mount: true by default.
The mount also answers its own root: /api reaches the route api registers
at "/". A path under the mount that api has no route for is a 404 from the
app, and redirects keep the prefix, so /api/Users goes to /api/users.
Error Handler
index.tsapp.error((req, res, err) => { switch (err.statusCode) { case 404: return notFound(req, res, err); default: return unhandled(req, res, err); } });
The error handler runs when a route handler throws or when no route matches.
Throwable error types from @elements/app:
AuthError(401): unauthenticated.ForbiddenError(403): authenticated but not permitted.NotFoundError(404): resource missing.ValidationError(422): invalid input.NotAcceptableError(406): request body unparseable.PartitionMismatchError(400): a LiveTable row outside the view's partition.
Throw them directly when something is wrong. For a missing row,
firstOrThrow throws the NotFoundError for you, with your message:
Examplelet room = sql(` select * from rooms where id = ${req.params.id} `).firstOrThrow(`room ${req.params.id} not found`);
Internal or unsafe errors are wrapped in ServerError before reaching the
client.
First-Match-Wins
Routes match in registration order. The first route that matches wins. Order specific routes before wildcards:
index.ts// correct app.route("/api/users", listUsers); app.route("/api/*", api); // wrong: api/* catches /api/users first app.route("/api/*", api); app.route("/api/users", listUsers);
Canonical URLs
Each page has one URL. A request that differs from a route only in its trailing slash or its case gets a 301 to the canonical form, so search engines see one page, not two.
By default the canonical form has no trailing slash: /about/ redirects to
/about. Set server.trailingSlash in config.jsoc to "always" to make the
slash form canonical instead, so /about redirects to /about/. Use it when a
site you are replacing already ranks with slash URLs:
config.jsocserver: { trailingSlash: "always", // or "never", the default },
Routes are written without the slash either way (app.route("/about", ...)),
and both forms reach the same handler. The root / and a path ending in a file
name, such as /feed.xml, never take a slash. Only GET and HEAD are
redirected: a POST to /save/ runs the /save handler, because a 301 would
ask the client to repeat the write somewhere else.
Redirecting
redirect(url) sends the visitor somewhere else. It is the same call templates
use, so a guard reads identically in a route, in a use() handler, and in a
template event handler.
app/pages/dashboard/index.tsimport { Request, Response, redirect, session } from "@elements/app"; export default function route(req: Request, res: Response) { if (!session.isLoggedIn()) { redirect("/signin"); return; } return new dashboard(); }
It writes a 302 and ends the response. It is not control flow: the handler keeps running, and a value returned afterward is discarded rather than written over the redirect, so both the early-return shape above and a plain fall-through work. Return early when you want the rest of the handler skipped.
For a status other than 302, use res.redirect(url, 301).
The Request and Response
req and res are enhanced Node IncomingMessage and response objects.
Request and responsereq.method req.url // current routing-context URL (sub-routers may mount-strip a prefix) req.originalUrl // original URL, never modified by routers req.parsedUrl // URL object for the current routing context req.params // merged: query < body < path. path wins. req.path // path-only params from the URLPattern match req.query // query-string only (string | string[] values) req.body // parsed body (json, form-data with File instances, urlencoded) req.bodyBuffer // raw body buffer res.status(404) res.send(value) // any sendable: string, object, html instance res.redirect("/", 301) // status other than 302; otherwise use redirect(url) res.end()
Access the current session via the global session namespace:
index.tsimport { session, AuthError } from "@elements/app"; app.route("/me", (req, res) => { if (!session.isLoggedIn()) { throw new AuthError(); } return sql<User>(` select * from users where id = ${session.getOrThrow("userId")} `).firstOrThrow("not found"); });
Related
start: theAppinstance,app.start(), application structure.rpc: the@rpcboundary, authorization, error catalog.session: reactive session, login/logout, sliding window.html: HTML templates and the route-to-page flow.