File routes
Routes should be discoverable from the repository tree. A definePage route supports two authoring shapes (#960): shape 1 exports tagName to name a content element registered with defineElement(tagName, …) and the page render returns that tag; shape 2 omits the export and the page render owns the markup directly. In both shapes the page itself always registers under the route-path tag (app/routes/index.tsx becomes index-page) — on a definePage route the tagName export names the content element only and never drives page registration.
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.
renderIntent.revalidate
revalidate is recorded on the route but remains inert — it does not enable caching. Treat it as unstable (@experimental); no public ISR delivery version is assigned.
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/routes/guestbook.tsx
import {
definePage,
fail,
type OpenElementActionFailure,
redirect,
useActionData,
useLoaderData,
} from '@openelement/app';
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()));
},
};
const GuestbookPage = definePage({
renderIntent: { mode: 'dynamic' },
render({ request }) {
const { entries } = useLoaderData() as GuestbookData;
const actionData = useActionData() as GuestbookActionData | undefined;
const echoed = request ? new URL(request.url).searchParams.get('echoed') : undefined;
return (
<main>
<h1>guestbook</h1>
<form method='post' data-open-enhance>
<input name='message' type='text' value={actionData?.message ?? ''} />
<button type='submit'>Send</button>
<button type='submit' formaction='?/shout'>Shout</button>
</form>
{actionData?.error ? <p role='alert'>{actionData.error}</p> : null}
{echoed ? <p>echo={echoed}</p> : null}
<ul>{entries.map((entry) => <li>{entry}</li>)}</ul>
</main>
);
},
});
export default GuestbookPage;
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.