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 (#960), but on a definePage route that export names the content element only and never drives page registration: the page itself always registers under the route-path tag (app/routes/index.tsx becomes index-page). Generated build entries register every admitted route and island class — route modules never self-register.
Metadata
Navigation and generated docs rely on route metadata.
Data boundary
Keep data loading separate from presentation markup.
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. Pages that export an action must declare 'dynamic' — the build rejects prerendered action pages. This behavior is frozen under ADR-0122.
Form actions
A dynamic 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 under ADR-0122.
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: 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'>{this.error}</p> : <span></span>}
{this.echoed ? <p>echo={this.echoed}</p> : <span></span>}
<ul>{this.entries.map((entry) => <li>{entry}</li>)}</ul>
</main>
);
}
}
app/routes/guestbook.tsx
import {
definePage,
fail,
type OpenElementActionFailure,
redirect,
} from '@openelement/app';
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 ?? '',
};
},
});
Action fetch negotiation
Fetch-based action posts are recognized by the x-openelement-action header (exported as ACTION_FETCH_HEADER from @openelement/app): 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 — success / failure / redirect with status and data — while error outcomes answer RFC 9457 problem+json (type/title/status/detail, #863). 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 (ADR-0129) 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 only { params } (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 (#570, ADR-0119 frozen SPA semantics).
Integration recipes
better-auth — session read in loaders, auth endpoints mounted as API routes, authorization in actions (doc-level recipe).
Drizzle — queries in loaders, mutations in actions, connection secrets on ctx.env only (doc-level recipe).
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 on middleware.use, scoped to action POSTs, 429 problem+json over the limit; verified against a scratch app built from repo source.
FileDataAdapter (filesystem data) — the ADR-0095 recipe: a read-only JSON-file adapter with the unstorage read surface (getItem/keys), used from loaders; verified against a scratch app built from repo source.
Auth guard (better-auth middleware) — redirects anonymous users out of a protected route group (303) and passes session identity through to loaders; guard mechanics verified, better-auth call stubbed.
Supabase (reference starter) — @supabase/ssr server client writing session cookies over the ADR-0129 response-header channel, authorization re-checked in loaders/actions, RLS-first notes / Storage / Realtime; every code block lifted from examples/supabase-cloudflare-starter and qualified against a real Supabase project.