API reference
Every public export from @devorajs/core, grouped by what it's for. This is the code-level companion to Core concepts and Backend — those pages explain the ideas, this page is what to actually import.
Where things come from
Three entry points, not one — importing from the wrong one is the most common mistake:
| Import path | Use it from | Has |
|---|---|---|
@devorajs/core | Any ssr/ssg/isr/streaming route or server file | Everything below except the client-only row |
@devorajs/core/client | A csr route, or any island component | PageShell, AppHeader, theme — browser-safe subset only |
@devorajs/core/config | devora.config.ts / app.config.ts | defineProject, defineApp |
Why the split exists
The main entry's export chain reaches real Node builtins (node:crypto in session/CSRF code, node:fs in the router) — fine for a server file, but a renderMode: "csr" route is also built for the browser. Importing PageShell from the main barrel inside a csr route breaks that client build outright. Use @devorajs/core/client there instead — see the csr example.
Page & navigation
PageShell — wraps a route's content with the shared header + content container every app uses, so a page looks the same whether it's marketing, dashboard, or admin.
function PageShell(props: {
appName?: string; // shown as a badge in the header — omit for e.g. marketing
nav?: NavLink[]; // this app's own link set (see apps/*/nav.ts)
footer?: ReactNode; // renders inside the shared <footer>, above "Built with devora.js"
footerLinks?: NavLink[]; // plain links in the footer's bottom row (since 0.3.2)
children: ReactNode;
}): JSX.ElementNavLink — the shape nav expects: { label: string; href: string }. AppHeader is the header alone (what PageShell renders internally) — reach for it directly only if you're composing your own page chrome instead of using PageShell.
Since 0.3.2 the framework's stylesheet and favicon are only added to pages that render PageShell or AppHeader; head.theme overrides that. The shell's markup is a .devora-shell column (header, main.devora-page, footer), so the footer stays at the bottom of short pages. Its CSS custom properties (--devora-bg, --devora-surface, --devora-fg, --devora-accent-from, ...) can be reused in your own CSS.
Config
defineApp(config) — apps/<name>/app.config.ts's default export. Identity wrapper for type inference; every field is optional.
| Field | Type | Does |
|---|---|---|
defaultRenderMode | RenderMode | Applies to any route in this app with no renderMode export of its own |
security.csp | string | Overrides the default Content-Security-Policy for this app — see Security model |
security.hsts | boolean | Toggles the HSTS header |
security.frameOptions | "DENY" | "SAMEORIGIN" | Overrides X-Frame-Options |
security.noindex | boolean | Sends X-Robots-Tag: noindex on every response (an admin panel, an internal tool) |
sitemap | boolean | Serves /sitemap.xml for this app. Opt-in, default false |
backendOnly | boolean | Pure-API app — skips the client build and SSR entry entirely. See Backend |
apiDir | string | Folder for API route files, served under /api. Default "api"; new apps use "endpoints" |
head | HeadConfig | { lang?, stylesheets?, links?, meta?, scripts?, theme? } for every page — see Pages, forms and errors |
maxBodyBytes | number | Largest request body for forms and API routes; larger gets a 413. Default 10 MB |
cors | CorsConfig | { origins, credentials?, methods?, allowHeaders?, exposeHeaders?, maxAge? } for API routes. Off by default — see Backend |
trustProxy | boolean | number | Trust X-Forwarded-* from that many proxies, for ctx.url, ctx.ip and secure: "auto". Default false for devora start, true in the Vercel/Netlify functions |
csrf | boolean | false: never issue the CSRF cookie (Bearer-only APIs); verifyCsrf() then passes only for Bearer-authenticated requests |
sessions | AppSessionsConfig | Per-app cookie overrides; activeSeconds/idleSeconds for auth: "isolated" apps |
prebuild | string | string[] | Shell commands devora build/deploy run in the app folder first (codegen, a CSS build); a non-zero exit stops the build |
vercel.runtime | string | Vercel function runtime, e.g. "nodejs22.x"; picked from Node/engines when unset |
defineProject(config) — the project-root devora.config.ts's default export: { apps: AppConfig[], shared: SharedConfig }. Each AppConfig is { name, dir, domain, auth?, devPort? }; SharedConfig is { core, backend?, auth, sessions? } (the project-wide default auth, overridable per app; no backend in a frontend-only project). sessions is { store?, activeSeconds?, idleSeconds?, cookie? } — where session records live (a module path or "memory"), how long they last, and the cookie's { secure?: true | "auto" | false, sameSite?: "lax" | "strict" | "none", domain? }; a store is required in production for any app with login, see Security model. AuthMode is shared isolated none — see Core concepts. RenderMode is "ssr" | "ssg" | "csr" | "isr" | "streaming" — see Render modes.
Route modules
What a file under routes/ may export — nothing here is required:
| Export | Signature | Does |
|---|---|---|
renderMode | RenderMode | How this route renders — see Render modes |
revalidate | { seconds: number } | isr only — how long a render stays fresh |
loader | (ctx) => Promise<Data> | Data | Server-side data for the page |
getStaticParams | () => Promise<Record<string,string>[]> | Required for ssg/isr on a dynamic route — one static file per entry returned |
action | (formData, ctx) => Promise<unknown> | Handles a form POST — returns redirect(to) (303), fail(status, data), or data for the page as actionData. File fields arrive as File objects |
meta | (data?) => { title?, description?, og?, head? } | Page <title>, description, Open Graph, and route-level <head> tags |
| default export | (props: RouteComponentProps) => JSX | The page's UI; props are { data?, csrfToken?, actionData? } |
The request context (ctx)
Passed to every loader, action, serverFn, and API route handler. Sessions are opaque, server-side, and revocable, reached by an HttpOnly cookie or an Authorization: Bearer header through one lookup — see Security model. Checking who someone is stays your code's job.
| Member | Signature | Does |
|---|---|---|
params | Record<string, string> | Values from any [param] segments in the matched route. A [...slug] catch-all is in here too, its segments joined with / ("a/b") |
catchAll | Record<string, string[]> | The segments a [...slug] or [[...slug]] matched, as arrays (ctx.catchAll.slug); empty for a route with none (since 0.3.4) |
url | URL | The request URL, query string included (ctx.url.searchParams.get("q")); scheme and host follow trustProxy |
headers | Headers | Request headers (empty at build time for ssg) |
method | string | The HTTP method |
ip | string | undefined | Client IP: the TCP peer, or from the right of X-Forwarded-For when trustProxy is set |
request | Request | A standard Request without a body (the body goes to action) |
session | SessionData | undefined | The current session's data, if any — from the cookie or a Bearer token alike. Typed once with declare module "@devorajs/core" { interface Register { session: { userId: string } } }; unknown otherwise |
sessionTransport | "cookie" | "bearer" | undefined | Which transport authenticated this request |
requireAuth() | () => void | Throws a 401 HttpError if there's no valid session on either transport |
setSession(data, options?) | (data: unknown, { transport?: "cookie" | "bearer" }?) => Promise<string> | Starts a new server-side session holding data and resolves to its ID. "cookie" (the default for a request that isn't already Bearer-authenticated) sets the session cookie; "bearer" sets none — return the ID to the client. Always issues a fresh ID, revoking any session the request already had |
revokeSession(sessionId?) | (sessionId?: string) => Promise<void> | Deletes the current session server-side (and clears its cookie) — dead on both transports immediately. Pass another session's ID to revoke that one instead |
clearSession() | () => Promise<void> | Alias for revokeSession() with no argument |
verifyCsrf(submitted) | (FormData | string) => void | Throws a 403 HttpError if the token doesn't match this browser's CSRF cookie — form posts pass FormData, a same-origin API call passes the header value as a plain string. Passes without a token for a Bearer-authenticated request |
The framework waits for setSession/revokeSession's store write before sending the response even if you don't await it — but awaiting is clearer, and required if you need the ID. On an app with auth: "none", all five session methods above throw a clear error instead of silently no-opping if called — see Core concepts.
Actions, redirects & CSRF
import { fail, redirect, type RequestContext } from "@devorajs/core";
export async function action(formData: FormData, ctx: RequestContext) {
ctx.verifyCsrf(formData);
const username = String(formData.get("username") ?? "");
if (!username) return fail(400, { error: "Enter a username" }); // re-render with 400, error in actionData
// Check the password against your own database here, before this line.
await ctx.setSession({ username }); // sets the HttpOnly session cookie
return redirect("/dashboard"); // 303 See Other, so a refresh doesn't resubmit
}| Export | Does |
|---|---|
redirect(to, status?) | 303 after a POST, 302 from a loader; pass 301 | 302 | 303 | 307 | 308 to choose. A loader may also throw redirect(...) |
fail(status, data) | Re-render the page with a 4xx/5xx status and data as the actionData prop |
CsrfField({ token }) | The hidden input a <form> needs; a page receives its own csrfToken as a prop |
HttpError(status, message) | Throw it from a loader, action or API route: the response keeps that status. On a page it renders the app's error.tsx |
ErrorPageProps | { status, message }, the props of apps/<name>/error.tsx (also used for 404s). message is safe to show |
Backend — serverFn, modules & API routes
serverFn(fn) — the original backend primitive: a plain function reference a route imports and calls directly, never dispatched dynamically by name. clientOnly(loader) guarantees a component never runs server-side (touches window at module scope, say).
import { serverFn } from "@devorajs/core";
export const updateSettings = serverFn(async (input, ctx) => {
ctx.requireAuth();
return db.settings.update(input);
});defineModule(def, setup?) — organizes backend logic into scoped units, see Backend. A DevoraModule exposes .register(child) (mounts a child's routes, namespaced under its name) and .getRoutes() (the flattened table an app's dispatcher reads).
apiRoute(handler) — a plain request handler for a file in the app's API folder (apiDir: endpoints/ in new apps, api/ by default), served under /api, decoupled from page rendering. Optionally export a methods array alongside handler to reject unlisted HTTP methods with a real 405 before the handler runs.
API routes always answer in JSON since 0.3.0. apiRoute() wraps your handler (before 0.3.0 it returned it unchanged): an uncaught error becomes a { "message": "..." } JSON response instead of the dev server's HTML error page. Throw new HttpError(status, message) for a deliberate failure — its status and message are sent as-is. Any other error with a numeric status (or statusCode) from 400–599 is treated the same way, so an existing error class of your own keeps working. Anything else is a 500; its real message is shown in dev and replaced with "Internal Server Error" in production (the error is logged server-side either way). The dispatcher applies the same rule around every api/** request, so a handler exported without apiRoute(), or middleware wrapped outside it, doesn't produce HTML either.
// apps/web/endpoints/orders/[id].ts
import { apiRoute, HttpError } from "@devorajs/core";
const orders = new Map([
["1", { id: "1", total: 42 }],
["2", { id: "2", total: 5000 }],
]);
export const methods = ["GET"];
export const handler = apiRoute((req) => {
const order = orders.get(req.params.id!);
if (!order) throw new HttpError(404, "Order not found"); // → 404 {"message":"Order not found"}
if (order.total > 1000) throw new Error("fraud check unavailable"); // → 500, message hidden in production
return { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify(order) };
});A request under /api that matches no route file — including bare /api — gets a JSON 404, { "message": "Not found" }, never an HTML page. Page routes are unaffected: their errors and 404s stay HTML.
| Type | Shape |
|---|---|
ApiRequest | { method, url, headers, params, catchAll, body: Buffer } — params are strings, with a catch-all joined by /; catchAll has the arrays. body is raw bytes, not pre-parsed, so a webhook can verify an HMAC against the exact bytes first |
ApiResponse | { status, headers?, body? } |
Sessions — the store
defineSessionStore(store) — type helper for the default export of the module shared.sessions.store points at. A SessionStore is { get(key), set(key, record), delete(key) } (each may be sync or return a promise); a SessionRecord is { data, activeExpiresAt, expiresAt } (epoch milliseconds). The key is an HMAC of the session ID, never the ID itself. createMemorySessionStore() returns the in-process store "memory" uses — handy in tests. getSessionState(record) returns "active" | "idle" | "dead". See Security model for a working SQLite-backed store.
signSession/verifySession are still exported but deprecated — the framework no longer uses them, and a token signed with them can't be revoked. Replace a hand-rolled Bearer scheme built on them with setSession(data, { transport: "bearer" }).
Middleware
withMiddleware(handler, ...middlewares) — composes plain functions, left to right; the call site's argument order is the execution order. defineMiddleware(mw) exists only for type inference.
import { withMiddleware, apiRoute } from "@devorajs/core";
const logging = (next) => async (req, ctx) => {
console.log(req.method, req.url);
return next(req, ctx);
};
export const handler = withMiddleware(rawHandler, logging, requireAuthMw);fromExpressMiddleware(mw) adapts an Express/Connect (req, res, next) middleware — supports reading req.method/url/headers and calling res.setHeader/getHeader/end/next(), not a full Node stream body or Express-specific extensions. fromFastifyPlugin(name, plugin) adapts a real Fastify plugin's route registrations into a DevoraModule — throws immediately if the plugin calls an unsupported method like addHook/decorate, rather than silently ignoring it.
Islands & client-only
import { island, Island } from "@devorajs/core";
// or "@devorajs/core/client" inside a csr route/island component itself
const ThreeScene = island(() => import("./ThreeScene"));
export default function Page() {
return <Island component={ThreeScene} props={{ spin: true }} />;
}island(() => import("specifier")) — only this literal call shape is accepted (no dynamic/computed specifiers). The build finds islands in any module the app imports, not only route files, and since 0.3.2 any other shape, or a path that doesn't resolve, fails devora dev/devora build with the file and line. <Island component props /> is what a route actually renders around one.
Dev reload safety
registerDisposable(fileUrlOrPath, dispose) — runs dispose right before Vite's dev server reloads the file at fileUrlOrPath (pass import.meta.url). Use it to close a stale native-addon DB connection cleanly instead of leaving it to a finalizer. Dev-only — nothing to dispose in a production process that never reloads modules. See Backend.
ISR revalidation
revalidatePath(staticOutDir, routePath) — forces an isr route's cached render to regenerate on its next request, ahead of its own revalidate window. See Render modes.