Pages, forms and errors
What a file under apps/<name>/routes/ can do, as of @devorajs/core 0.3.5 (a tag such as 0.3.2 marks the release a feature arrived in). Sessions and cookies are on Security model; API routes on Backend.
The request: ctx.url, ctx.headers, ctx.ip
loader(ctx) and action(formData, ctx) get the request 0.3.2:
import type { RequestContext } from "@devorajs/core";
export function loader(ctx: RequestContext) {
const q = ctx.url.searchParams.get("q") ?? ""; // query string
const lang = ctx.headers.get("accept-language"); // a standard Headers
return { q, lang, ip: ctx.ip, method: ctx.method };
}ctx.urlis aURLof the request as the client sent it. Scheme and host come from the request, and fromX-Forwarded-Proto/-Hostwhen the app setstrustProxyinapp.config.ts. Like any Host-derived URL, check the host against your own domain before using it in links you email out.ctx.ipis the socket's peer address. WithtrustProxy: trueit's the address your proxy saw, read from the right ofX-Forwarded-For;trustProxy: 2trusts two proxies (a CDN in front of nginx). On Vercel/Netlify it's the platform's client IP.ctx.requestis a standardRequest(no body) for libraries that want one.- At build time (
ssg/isr),ctx.urlis the page's own URL on the app's domain,headersis empty andipis undefined.
To type ctx.session once instead of casting in every route:
// apps/<name>/session.d.ts (any .d.ts the app's tsconfig includes)
import "@devorajs/core";
declare module "@devorajs/core" {
interface Register {
session: { userId: string; role: "admin" | "user" };
}
}Forms: what an action can return
import { CsrfField, fail, redirect, type RequestContext, type RouteComponentProps } from "@devorajs/core";
export async function action(formData: FormData, ctx: RequestContext) {
ctx.verifyCsrf(formData);
const code = String(formData.get("code") ?? "");
if (!(await isValid(code))) return fail(400, { error: "Wrong code", code });
return redirect("/welcome"); // 303 See Other
}
export default function Verify({ csrfToken, actionData }: RouteComponentProps<unknown, { error?: string }>) {
return (
<form method="post">
<CsrfField token={csrfToken} />
{actionData?.error ? <p role="alert">{actionData.error}</p> : null}
<label htmlFor="code">Code</label>
<input id="code" name="code" />
<button type="submit">Verify</button>
</form>
);
}| Return | Response |
|---|---|
redirect(to) | 303 after a POST (so a refresh doesn't resubmit), 302 from a loader; redirect(to, 301 | 302 | 307 | 308) to choose |
fail(status, data) | The page re-rendered with that 4xx/5xx status; data is the actionData prop |
| anything else | The page re-rendered (200) with it as actionData |
A loader can also return redirect(...) or throw redirect(...) (for example, "not logged in → /login").
Uploads
<form method="post" encType="multipart/form-data"> works with no setup: file fields arrive in formData as File objects (file.name, file.size, await file.arrayBuffer()). The body limit for forms and API routes is maxBodyBytes in app.config.ts (default 10 MB); a larger body gets a 413 the browser actually receives. Malformed multipart is a 400. On serverless platforms the platform's own body limit is lower (Vercel's is 4.5 MB), so large files belong in direct-to-storage uploads (Backend capabilities).
Errors and the error page
A thrown HttpError keeps its status on page routes too 0.3.2: requireAuth() gives 401, verifyCsrf() 403, and your own throw new HttpError(404, "No such order") 404. Anything else is a 500 whose message is shown in dev and replaced by "Internal Server Error" in production; the real error is logged server-side.
These responses, and unmatched URLs (404), render the app's error page, apps/<name>/error.tsx. New apps get one; without it a small built-in page is used. message is always safe to show.
import { PageShell, type ErrorPageProps } from "@devorajs/core";
export function meta({ status }: ErrorPageProps) {
return { title: status === 404 ? "Not found" : `Error ${status}` };
}
export default function ErrorPage({ status, message }: ErrorPageProps) {
return (
<PageShell>
<h1>{status}</h1>
<p>{message}</p>
</PageShell>
);
}<head>: stylesheets, fonts, manifest, meta, scripts
App-wide, in app.config.ts:
export default defineApp({
head: {
lang: "en",
stylesheets: ["/styles.css"],
links: [
{ rel: "icon", href: "/favicon.png" },
{ rel: "manifest", href: "/manifest.webmanifest" },
],
meta: [{ name: "theme-color", content: "#0a0a12" }],
scripts: [{ src: "/analytics.js", defer: true }],
},
});Per route, through meta():
export function meta(data) {
return {
title: data.title,
head: { links: [{ rel: "canonical", href: `https://example.com/p/${data.slug}` }] },
};
}- Files in the app's public folder (
/styles.css,/favicon.png) are served as-is. - Scripts are external
srconly: the default CSP blocks inline scripts. - Fonts or styles from another origin need your
security.cspto allow that origin.
The framework's own stylesheet is opt-in (since 0.3.2)
The theme CSS and the default Devora favicon are only added to pages that render PageShell/AppHeader. CSR pages get them by default, because the server can't see what they render. head: { theme: false } turns them off everywhere; theme: true adds them to every page. A links entry with rel: "icon" replaces the default favicon.
CSS imported by a route
import "./page.css" (or a component's own import "./card.css") is linked in the page's <head> automatically — in dev, in the production build, and in ssg/isr HTML. In production the files are emitted to /assets/ with hashed names. ?inline/?raw/?url imports are values, not stylesheets, and are left alone.
What runs at build time
devora build only imports (and so runs) routes that are ssg or isr, and what they import. It reads each route's mode from its source (export const renderMode = "ssr"), so a module that needs secrets at import time (a database or storage client) doesn't crash a build that doesn't have them. Two cases still run at build: ssg/isr routes and everything they import, and a route whose renderMode isn't a plain string literal. For those, create clients lazily, on first use.
The shell: PageShell and its classes
PageShell renders a sticky header, a centred content column and a footer that stays at the bottom of short pages. Props: appName (the badge next to the logo), nav (header links, which wrap onto their own row on narrow screens), footerLinks (links next to the footer logo) and footer (any footer content).
| Class | For |
|---|---|
devora-lead | A larger intro paragraph |
devora-card | A bordered card |
devora-grid | A responsive grid of devora-cards |
devora-button | A link styled as the primary button (<button>s already are) |
devora-error / devora-status | The centred error / status layout |
devora-gradient-text, devora-pill | The brand gradient on text; a small status pill |
Forms inside the page are styled as cards, with full-width fields and labels above them. Everything is CSS custom properties (--devora-*), dark by default with a light variant from prefers-color-scheme; override any token in your own stylesheet.