Manual Router

Router

elements man router Read as markdown

Routing 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 by design. The browser attaches to whatever the route renders.

At a Glance

// index.ts
import { 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 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.ts
import html from "./template";

export default function route(req, res) {
  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 string is sent as text/plain.
  • An object or array is serialized as Elements json and sent.
  • void or undefined means the handler used res directly.
app.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.

app.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:

function route(req, res) {
  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.

app.route("/api/users", listUsers);                                          // any method
app.route({ method: "post", path: "/api/users", handler: createUser });      // POST only
app.route({ method: ["get", "post"], path: "/api/data", handler: data });    // GET or POST

Domain Matching

Restrict a route to a specific hostname.

app.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.

app.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.

import { 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. Reach for use() when you need a cross-cutting concern Elements doesn't already cover: request logging, custom headers, feature flags.

Sub-Routers

Nested routers mount at a path prefix.

import { 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.

Error Handler

app.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.
  • ScopeMismatchError (400): LiveTable scope mismatch.

Throw them directly when something is wrong:

import { NotFoundError } from "@elements/app";

let room = sql(`select * from rooms where id = ${req.params.id}`).first();
if (!room) {
  throw new NotFoundError(`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:

// 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);

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.

import { redirect, session } from "@elements/app";

export default function route(req, res) {
  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.

req.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:

import { 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: the App instance, app.start(), application structure.
  • rpc: the @rpc boundary, authorization, error catalog.
  • session: reactive session, login/logout, sliding window.
  • html: html templates and the route-to-page flow.