Routing and Data
Routes are file-based surfaces with explicit metadata and data boundaries.
Routes are file-based surfaces with explicit metadata and data boundaries.
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.
Navigation and generated docs rely on route metadata.
Keep data loading separate from presentation markup.
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 (0.42 line, unfrozen).
revalidate is recorded on the route but inert in the 0.42 line — it does not enable caching; it is reserved for the 0.44 ISR work. Treat it as unstable (@experimental).
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 (0.42 line, unfrozen).
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.
Request-time ('dynamic') loaders/actions run on the server with the Web-standard context { request, params, env, platform, route } and the fail()/redirect() protocol. 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).
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;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.