File routes
Routes should be discoverable from the repository tree. A definePage route default-exports the compiled page element class wrapped in definePage(PageClass, { ... }); the page class lives in a non-route module (for example app/components/) and owns the markup as its compiled render program. A route may still export tagName to name a content element, but on a definePage route that export names the content element only and never drives page registration: the page itself registers under its compiled class's @element(tag) — SSR resolves the tag from the compiled Part Program — with the route-path-derived tag (app/routes/index.tsx → index-page) only as a fallback. Generated build entries register every admitted route and island class — route modules never self-register.
Metadata
Navigation and generated docs rely on route metadata, and a route declares it in exactly one place depending on what the route is.
A content page declares it in Markdown frontmatter: title (required), lede, order (required, sorts within its section), section (the sidebar group, defaulted per collection), and optional navLabel when the sidebar label should differ from the title. A zh sibling supplies its own translated values from the same fields. The site's nav generator reads frontmatter through the same loader and schema the page body uses, so the sidebar cannot describe a page differently from the page itself.
A code route declares export const meta = { section, label, order } in the route module — section groups it in the sidebar, label is the short nav label, order sorts within the group. A section name that no basePath lists in the site's section map fails the nav generation rather than silently dropping the group from that page's sidebar. Routes with no meta and no frontmatter are simply absent from the sidebar; they are still routed.
Document metadata — the head of the page — is separate from navigation metadata. definePage(PageClass, { head }) declares the document title, description, canonical path and structured data, either as a static object or as a resolver receiving the same request-scoped context the props projector gets. The resolver must stay a pure function of that context: the Document seam resolves it per render and never fetches, caches, or schedules loaders on its own. For content pages, title and lede come from the render locale's frontmatter with the English original as the fallback, so a page whose translation is missing a field still answers with a complete head.
Neither kind of metadata is markup: nothing here renders into the page body.
Data boundary
Keep data loading separate from presentation markup. The boundary is explicit and narrow: a loader returns data, definePage's props projector maps that data onto the page's compiled properties, and the page's render() reads only this.<property>.
A route loader runs server-side for a 'dynamic' page (or at build time for a 'static' one, once per getStaticPaths() entry) and returns a plain value; it is never handed the element and never touches the DOM. The props projector is the single deterministic seam between request scope and the compiled page — it receives { data, actionData, params, request, route, meta } (plus locale when i18n is configured) and returns the properties the page declared, so the same projection runs for the static artifact, the request-time server and the SPA bootstrap. Omit props and the default projection applies: route params first, then the loader-data record's own entries, with dangerous keys filtered so a hostile payload cannot re-prototype the projection. Extra entries are ignored, because the compiled serializer consumes only the properties the page declared.
That separation is what makes the two chains interchangeable and the markup testable: a page's compiled render program depends on nothing but its properties, so it can be serialized at build time, re-rendered per request, or created in the browser from the same data. It is also the seam where the failure channels attach — an expected validation failure arrives as actionData, not as an exception, and fail()'s echo re-enters the page through the same projector.
Two consequences worth writing down: never let a loader return a DOM node, a class instance or anything the serializer would have to guess at (props are projected, not serialized); and never read the request directly from render() (a compiled page has no request scope — everything it may render must pass through the projector).
Rendering modes
renderIntent.mode selects where a page renders: 'static' (default) prerenders at build; 'dynamic' skips prerendering and renders per request through the generated dist/server entry, running the route loader on every request. A page that exports an action may stay 'static' — a hybrid page: its GET is prerendered and served from the static artifact, while its action POST is dispatched to the generated server entry at request time. Choose 'dynamic' when the GET itself must run per request (per-request loaders, response headers, non-repeatable content). This behavior is frozen.
Server function pages
When a page is one-shot static text — a legal notice, a landing section — the compiled-class machinery is more than it needs. A server function page is the route module's own exported TSX component function wrapped in definePage(Component, descriptor?): the compiler lowers the function into a generative HTML constructor (the same semantic checks and the same escaping as every compiled component — text crosses one escape channel, attributes another), and the render output is server-only by construction. A server function page produces no Part Program, no @property state machinery, and never reaches the island, static-component, or client-chunk surfaces; with no island composed, the page ships zero client JS.
// app/routes/about.tsx
import { definePage } from '@openelement/router';
export const loader = async () => ({ title: 'About' });
export function AboutPage(props: { title: string }) {
return <main><h1>{props.title}</h1></main>;
}
export default definePage(AboutPage, { head: { title: 'About' } });The component function must be an exported declaration of THIS module passed by identifier — an inline arrow or an unexported local is outside the compiler grammar (runtime JSX has no interpreter). Dynamic text interpolates through JSX ({props.title}) and is escaped by the compiler; props.children is the compiler-owned composition channel (a caller's JSX children arrive as final HTML), and a loader payload carrying a children key is refused before the render runs rather than injected.
Everything around the page stays the same as the compiled-class path: the loader runs and the props projector (or the default projection) builds the props record; the render output flows through the same renderer wraps, layout wrap, and document wrap — so the HTTP/DSD contract, the head resolution, the redirect/not-found channels, and the error projector's 500 re-render (which hands the error-projected props to the same render function) behave exactly as they do for a compiled page. renderIntent.stream is a compiled-Part-Program channel and is rejected on server function pages; use a compiled definePage class route when a page must stream.
Form actions
A route may export an action({ formData }) — plain HTML forms work without JavaScript: validation failures return fail(4xx, data) and re-render with the echo at fail()'s status (conventionally 422), successes answer 303 (PRG). Named actions dispatch via formaction='?/name'. Forms marked data-open-enhance submit via fetch and morph the returned document into place: hydrated islands whose light DOM did not change keep their state, data-open-preserve exempts a subtree, and the URL follows the PRG target. An action must be safe to re-run after a failed validation; these application-loop semantics are frozen.
app/components/page-guestbook.tsx
// Compiled by the open:compiled-element transform.
import { element, OpenElement, property } from '@openelement/element';
@element('guestbook-page', { root: 'shadow-open' })
export default class GuestbookPage extends OpenElement {
@property({ reflect: false, attribute: false })
entries: Array<{ id: string; text: string }> = [];
@property({ reflect: false, attribute: false })
message = '';
@property({ reflect: false, attribute: false })
error = '';
@property({ reflect: false, attribute: false })
echoed = '';
render() {
return (
<main>
<h1>guestbook</h1>
<form method='post' data-open-enhance>
<input name='message' type='text' value={this.message} />
<button type='submit'>Send</button>
<button type='submit' formaction='?/shout'>Shout</button>
</form>
{this.error ? <p role='alert' data-message={this.error}>Please fix the errors above.</p> : <span></span>}
{this.echoed ? <p data-echoed={this.echoed}>Saved.</p> : <span></span>}
<ul>{this.entries.map((entry) => <li key={entry.id}>{entry.text}</li>)}</ul>
</main>
);
}
}app/routes/guestbook.tsx
import {
definePage,
fail,
type OpenElementActionFailure,
redirect,
} from '@openelement/router';
import GuestbookPage from '../components/page-guestbook.tsx';
interface GuestbookData {
entries: string[];
}
interface GuestbookActionData {
error?: string;
message?: string;
}
export async function loader(): Promise<GuestbookData> {
return { entries: await listEntries() }; // app data layer
}
export function action(
ctx: { formData: FormData },
): OpenElementActionFailure<GuestbookActionData> {
const message = String(ctx.formData.get('message') ?? '').trim();
if (!message) {
return fail(422, { error: 'message is required', message });
}
throw redirect('/guestbook?echoed=' + encodeURIComponent(message)); // 303 PRG
}
// Named actions dispatch via formaction='?/name'.
export const actions = {
shout(ctx: { formData: FormData }): never {
const message = String(ctx.formData.get('message') ?? '').trim() || 'silence';
throw redirect('/guestbook?echoed=' + encodeURIComponent(message.toUpperCase()));
},
};
// The props projector is the single deterministic seam mapping request scope
// onto the compiled page properties.
export default definePage(GuestbookPage, {
renderIntent: { mode: 'dynamic' },
props({ data, actionData, request }) {
const action = actionData as GuestbookActionData | undefined;
const echoed = request ? new URL(request.url).searchParams.get('echoed') : undefined;
return {
entries: data?.entries ?? [],
message: action?.message ?? '',
error: action?.error ?? '',
echoed: echoed ?? '',
};
},
});Success data has no envelope — it travels through the PRG target. The actionData in the context above is the payload of a RETURNED failure (fail(status, data)) and nothing else. A successful action answers 303 (PRG), so the projector runs again on the redirected GET — exactly where a no-JS submission lands — and the success state to show is whatever that GET can read: the loader's re-run and the PRG target's own query state. The echoed value above is that pattern; a loader-side read is the other half:
// The redirected GET re-runs the loader: success state arrives here, not
// through a success envelope on the POST.
export async function loader(ctx: { request: Request }): Promise<GuestbookData> {
const posted = new URL(ctx.request.url).searchParams.get('posted');
return { entries: await listEntries(), posted: posted ?? '' };
}This is ADR-0120's three-state status rule, frozen: a successful non-GET action answers 303, an action endpoint never answers 200 with a success document, and validation stays a fail(4xx) return. The rule is about the NATIVE channel's status line — on the fetch channel the same success is HTTP 200 whose body is the redirect ActionResult ({ type: 'redirect', status: 303, location }), and the 303 is the value inside that body rather than the status of the response. The built-in enhancement follows it as a navigation; a programmatic caller reads it as data.
Action fetch negotiation
Fetch-based action posts are recognized by the x-openelement-action header (exported as ACTION_FETCH_HEADER from @openelement/router): the built-in morph enhancement sends enhance and receives the same full-HTML responses as the no-JS path; a programmatic caller sends true and receives the serialized ActionResult union — failure with status/data, or redirect with the PRG status/location. A validation failure comes back as 422 with application/json and the failure shape — the author's status and data, not a document. application/problem+json (type/title/status/detail) is the channel's PROTOCOL-error shape instead: a rejected cross-site post, an unknown action, an unparseable body. No header means a plain browser form post.
Two loader/action chains
Request-time ('dynamic') loaders/actions run on the server with the Web-standard context { request, params, env, platform, route, responseHeaders } and the fail()/redirect() protocol. responseHeaders is a mutable Headers channel merged into every response of the request — renders, redirects, 422 re-renders and fetch-channel JSON alike — so recipes can write session cookies; framework protocol headers always win on conflict. SPA-mode loaders/actions run client-side with { params, searchParams, signal } (a URLSearchParams and an AbortSignal, plus formData for actions) and signal failure by throwing — a throw is normalized into action data. The names are intentionally parallel, but the contexts differ: code written against one chain cannot assume the other's context.
Integration recipes
Validation (zod / valibot) — schema parse inside the action, fail(422) with the echo on failure; verified by the request-time fixture e2e gate.
Rate limit (fetch middleware) — fixed-window per-IP limiting (apps/saas/lib/rate-limit.ts), scoped to action POSTs, 429 problem+json over the limit; covered by the SaaS test suite.
Supabase (first-party SaaS) — @supabase/ssr server client writing session cookies over the response-header channel, authorization re-checked in loaders/actions, RLS-first notes / Storage / Realtime; implemented in apps/saas and covered by its test suite.
See also
- Core Concepts — the elements and islands these routes compose.
- Error Handling — the failure channels an action's return value feeds.
- API Routes — routes that answer requests without pages.