Guides

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.url is a URL of the request as the client sent it. Scheme and host come from the request, and from X-Forwarded-Proto/-Host when the app sets trustProxy in app.config.ts. Like any Host-derived URL, check the host against your own domain before using it in links you email out.
  • ctx.ip is the socket's peer address. With trustProxy: true it's the address your proxy saw, read from the right of X-Forwarded-For; trustProxy: 2 trusts two proxies (a CDN in front of nginx). On Vercel/Netlify it's the platform's client IP.
  • ctx.request is a standard Request (no body) for libraries that want one.
  • At build time (ssg/isr), ctx.url is the page's own URL on the app's domain, headers is empty and ip is 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>
  );
}
ReturnResponse
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 elseThe 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>
  );
}

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 src only: the default CSP blocks inline scripts.
  • Fonts or styles from another origin need your security.csp to 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).

ClassFor
devora-leadA larger intro paragraph
devora-cardA bordered card
devora-gridA responsive grid of devora-cards
devora-buttonA link styled as the primary button (<button>s already are)
devora-error / devora-statusThe centred error / status layout
devora-gradient-text, devora-pillThe 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.