Pages & Routing

Pages & Routing

JSails discovers routes from the filesystem. discoverRoutes(rootDir, { pagesDir?, apiDir? }) walks pages/ and api/ lexically — no module import, no connection. Only compiled .js/.mjs modules are discovered; symlinks are never followed; api/ routes mount under /api. Parameters are single-segment [id]; catch-all [...id] is rejected, not accepted as a literal path.

Page modules

A page is a compiled module with a function default export, an optional load(context) for async props, an optional getStaticPaths() for dynamic routes, and an optional revalidate (seconds) that opts the page's load result into the cache store when one is available.

// pages/index.tsx  (app-owned page)
import type { RequestContext } from 'jsails';

export async function load(context: RequestContext) {
  return { hello: 'world' };
}

export default function Home({ hello }: { hello: string }) {
  return <h1>{hello}</h1>;
}

preactPageRenderer + renderRoute render pages via Preact SSR (doctype plus a minimal document shell) and reject a SERVER_ONLY default component in static mode. Set "jsxImportSource": "jsails" so JSX resolves to a thin preact/jsx-runtime re-export; components are synchronous and async data belongs in load. JSX/Preact runtimes are not interchangeable.

Dynamic routes

A [param] filename segment is a single-segment parameter. A dynamic page provides getStaticPaths() so the static export knows which paths to render:

// pages/blog/[slug].tsx
export function getStaticPaths() {
  return [{ slug: 'hello-world' }, { slug: 'about' }];
}

API modules

An API module exports named HTTP-method handlers (request, context) => Response; there is no default-export guessing. The global authorize is default-deny — absent means every API request is rejected, and a request is allowed only when the callback resolves to exactly true (truthy non-boolean, throw, or rejection all deny). A per-module authorize can only further restrict.

// api/me.ts
export async function GET(request: Request, context: RequestContext) {
  return Response.json({ session: context.session });
}

HTTP extension hooks run in order after the body-limit/405 middleware and before the filesystem routes, receiving the real Hono app; hook routes are trusted code that own their own security — the default-deny pipeline covers only filesystem API routes.

Per-route middleware

A compiled page or API module may export middleware, an array of RouteMiddleware handlers (context, next) => Response | Promise<Response> applied in declared order before the terminal handler. A handler that returns without calling next() short-circuits the chain; a thrown/rejected handler surfaces as the standard sanitized 500. Middleware can never run before the default-deny gate.

// api/reports.ts
export const middleware = [
  async (context, next) => {
    if (!context.session) return new Response('Unauthorized', { status: 401 });
    return next();
  },
];

export async function GET(request: Request, context: RequestContext) {
  return Response.json({ ok: true });
}

The ordering invariant is fixed: body-limit/405 → session → origin/CSRF → global authorize → module authorize → global middleware → per-route middleware → handler. Global middleware is set via globalMiddleware: readonly RouteMiddlewareRef[] in the app config; extensions add to the global chain through configureMiddleware(handler) on the ExtensionRuntime. A named registry (middleware: Record<string, RouteMiddleware>) is built at assembly time; routes reference registered names (or inline functions) in their middleware export, resolved by resolveMiddlewareRefs. The chain is request-scoped — next() is callable at most once, a second call throws a MiddlewareError, and the returned Response is trusted producer output. validateMiddlewareList validates a module's middleware export structurally; runMiddleware composes the chain over a terminal handler.

Limits: no reordering of built-in pipeline steps; no middleware on framework-owned routes (/up, /_jsails/introspect, server-component updates, extension HTTP hooks); no route-path-keyed config map; no lazy/async name resolution. The static export never runs middleware.

Route groups

A pages/ directory named (name) (matching [A-Za-z0-9_-]+) is a route group: it contributes no URL segment but does scope layouts. Groups are pages-only; in api/ the (...) is not a safe static segment and is rejected by the existing literal check.

Nested layouts

A layout.js/layout.mjs file in any pages/ directory is a layout module, discovered lexically by discoverRoutes and excluded from the route manifest. It attaches to RouteManifestEntry.layouts as an ancestor chain (outermost first — closest to pages/ comes first). Layout modules carry no load, middleware, or getStaticPaths; their default export is (props: LayoutProps, context?) => RenderChild where props receives the page's resolved props plus the framework-reserved children slot (a colliding page prop loses). The function may be sync or async.

// pages/layout.tsx
export default function Layout({ children }: { children: unknown }) {
  return (
    <div>
      <nav>My Site</nav>
      <main>{children}</main>
    </div>
  );
}

renderRoute folds the chain inside-out: the innermost layout wraps the page, its result is wrapped by the next-outer, and so on. If the outermost layout emits <html>, the minimal document shell is skipped. Static export folds layouts identically.

v1 limits: layouts have no load, no middleware, and no SERVER_ONLY check (only the page component is checked); no loading/error boundaries, or parallel/intercepting routes; the starter's ui/layout.tsx remains app-owned and pages/layout.js is opt-in.

Static export

jsails build is a static export: it renders every page (expanding getStaticPaths), copies public/ byte-for-byte (symlinks rejected, dot-files skipped), and swaps the result into out through a staging directory. API routes are never rendered and are reported skipped.

jsails build --config jsails.app.js   # static export into `out`

The renderer seam is PageRenderer.render(entry, context, options) — the same interface for serve and build; configure it with renderer. The built-in renderer keeps the compiled-page loader and getStaticPaths contract. A custom renderer's returned string is trusted producer HTML — JSails never sanitizes it, so the renderer owns its escaping and safety.

Progressive streaming

A page module may export stream(context) → PageStream to stream HTML progressively during live serving instead of waiting for load + default to finish. PageStream accepts AsyncIterable<string>, ReadableStream<Uint8Array>, or a Node Readable:

// pages/feed.tsx
import type { RequestContext } from 'jsails';
import type { PageStream } from 'jsails/pages';

export async function* stream(context: RequestContext): PageStream {
  yield '<!DOCTYPE html><html><body>';
  for await (const item of fetchItems()) {
    yield `<article>${item.title}</article>`;
  }
  yield '</body></html>';
}

renderStreamResponse(stream, headers?) (jsails/pages) wraps the stream into a Response with chunked transfer encoding and X-Accel-Buffering: no so buffering proxies pass chunks through uninspected.

During live serving stream takes precedence over the string renderer (default + load); a module may define both exports. Static export (jsails build) ignores stream and renders default + load as usual.

Asset URLs and Turbo reload

createAssetUrlResolver(publicDir) maps a root-relative public asset path (/assets/app.js) to the same path with a content-derived query (/assets/app.js?v=<sha256>). Hashing streams the file through SHA-256 and is cached by mtimeMs + size, so an unchanged asset is never re-read. The resolver is a graceful-degradation seam, never a source of errors: a missing directory/asset, a traversal/absolute/hidden/backslash/query/fragment path, or a symlink all return the unversioned path unchanged, so browser-free SSR (where public/ may not exist) still renders, and a hostile path is never read from disk.

context.assetUrl is optional; Application and createApp attach the resolver to each request context and the static export attaches it to each synthesized page context. The starter layout tags the stylesheet and the module script with data-turbo-track="reload"; Turbo compares that URL across navigations, so a content change that keeps the same filename yields a new URL and forces a full reload instead of serving a stale deployment cache. This is the intentional exception to soft navigation: an unchanged asset keeps the same URL, so ordinary links stay fast soft navigations, while changed build assets reload. The script also carries data-turbo-eval="false" so Turbo does not re-evaluate the client entry on every navigation. When no resolver is wired in (e.g. browser-free SSR), loadLayout falls back to the plain /assets/app.css and /assets/app.js paths.

Next steps