Guides

Backend

Domain modules, API routes, middleware, backend-only apps, dynamic-route static params, and a dev reload-safety hook. Available since @devorajs/core@0.2.2 and @devorajs/cli@0.2.0; 0.3.2 added a configurable API folder (apiDir), CORS, request access and a status response at / for backend-only apps.

Explicit domain modules

defineModule() organizes shared backend logic (packages/backend) into scoped units — composing them is a plain function call, never reflection or metadata scanning (no dependency-injection container, ever — a locked decision for this framework).

const usersModule = defineModule({ name: "users", functions: { getUserProfile }, routes: {
  "/[id]": apiRoute(async (req, ctx) => { ... }),
}});

const app = defineModule({ name: "app" }, (root) => {
  root.register(usersModule);
});

A module's own functions/routes stay private to it — register() merges a child's routes into the parent's flattened route table, namespaced under the child's own name (usersModule's "/[id]" becomes "/users/[id]" once registered into app). Nothing flows the other way: a child never gets implicit access to its parent or siblings — if one module's logic needs another's, it imports it directly, the same as any other TypeScript code. This is a deliberate simplification of true Fastify-style scope inheritance (which would need passing a live context object into a child's setup callback — too close to the dependency-injection shape this framework rules out).

Generic API routes

The real gap this closes: a server function was previously only reachable via a page route's loader/action, so nothing like a payment-provider webhook had anywhere to live. apiRoute() is a plain request handler, decoupled from page rendering entirely — file-based under the app's API folder, matched by the exact same router as routes/ (dynamic segments included: endpoints/users/[id].ts serves /api/users/123).

The folder is apiDir in app.config.ts: "endpoints" in apps created by create-devora or devora add, "api" by default for older apps. Only the folder changes — URLs are always under /api. Avoid api/ for an app you deploy to Vercel with the app as the Root Directory: Vercel treats a top-level api/ folder as its own functions (see Deployment).

// apps/dashboard/endpoints/hello.ts
import { apiRoute } from "@devorajs/core";

export const handler = apiRoute((req, ctx) => {
  ctx.requireAuth();
  return { status: 200, body: JSON.stringify({ message: "hello" }) };
});

Ownership rule

Shared backend logic goes in packages/backend, exactly like serverFn already works — an app-local API file is the exception, for something genuinely app-specific (a webhook only that app receives).

Security, worth getting right

ctx.requireAuth()/ctx.session carry over cleanly from page routes. ctx.verifyCsrf() does not — its token is embedded server-side into a rendered <form>, and a third-party webhook was never handed one. It now also accepts a plain string (not just FormData) for a same-origin JSON call — read the token your own page already received as a csrfToken prop, send it back on a header, verify with ctx.verifyCsrf(headerValue). A webhook needs its own signature/HMAC check against a provider-issued secret instead — bring your own, same boundary as DB/auth. Security headers (CSP/HSTS/X-Frame-Options) apply to API routes too, by default.

Method allowlist

Export an optional methods array alongside handler to have unlisted HTTP methods rejected with a real 405, before the handler runs at all — closes a real footgun where a single if (req.method === "POST") { ... } else { ... } handler quietly let a PUT/PATCH/DELETE fall into the branch written for GET. Fully opt-in — a route with no methods field behaves exactly as before.

export const methods = ["GET", "POST"];
export const handler = apiRoute((req, ctx) => { ... });

What an API route always does

  • Answers JSON. A thrown HttpError (or anything with a 4xx/5xx status) becomes { message } with that status; an unexpected error is a 500 whose message is hidden in production; an unmatched /api/** path is a JSON 404.
  • Sees the request. req.url (path and query string), req.headers, req.params and the raw req.body bytes (not parsed, so a webhook can verify a signature first), plus ctx.url, ctx.headers and ctx.ip. Behind a proxy, set trustProxy so the scheme, host and client IP come from the forwarded headers.
  • Caps the body at maxBodyBytes (10 MB by default); a larger one gets a 413.

Two limits today: headers on the response is a plain object, so one response can send only one Set-Cookie header (sessions set through ctx.setSession() are separate and fine); and the body is sent in one piece — no server-sent events or WebSockets from an API route.

CORS

Off unless configured. With cors in app.config.ts, preflights are answered with 204 before any route runs, an allowed Origin is echoed back, and any other origin gets no CORS headers (the browser blocks it). Pages never get CORS headers.

// apps/api/app.config.ts
export default defineApp({
  cors: {
    origins: ["https://app.example.com", "https://*.example.com"],
    credentials: true, // cookies on cross-origin requests; can't be combined with origins: "*"
  },
});

Middleware

Two layers. Native, first-class: withMiddleware(handler, ...mw) composes plain functions, left to right — the call site's own argument order is the execution order, no hidden registration list to scan.

const logging = (next) => async (req, ctx) => { console.log(req.method); return next(req, ctx); };
export const handler = withMiddleware(rawHandler, logging, requireAuthMw);

Optional ecosystem adapters: fromExpressMiddleware(mw) wraps an Express/Connect (req, res, next) middleware — verified against the real published cors package, including a real OPTIONS preflight. Not full Express compatibility: it supports reading req.method/headers and calling res.setHeader/res.end/next(), not a real Node stream body or Express-specific extensions. fromFastifyPlugin(name, plugin) adapts a real Fastify plugin's route registrations (instance.get/post/..., instance.register()) into a DevoraModule — it throws immediately, rather than silently ignoring it, if a plugin calls an unsupported method like addHook or decorate.

Governance rule

Every use of either adapter — any point a third-party npm package starts running inside the request pipeline — is registered in one place, packages/backend/middleware.ts. A route opts in explicitly by importing from there; nothing runs implicitly for every route.

For worked examples of the middleware people reach for first — validation, logging, CORS, rate limiting, and file uploads — see Backend capabilities.

Backend-only apps

An app that's pure API needs no pages, no client build, no PageShell/theme wiring at all — set backendOnly: true in that app's app.config.ts. devora build/devora dev skip the Vite client build and the entry-server.tsx SSR entry entirely; the app can have zero routes/ directory and no react/react-dom dependency at all. devora add <name> --kind=api scaffolds one (in a project whose apps are all backend-only, that's the default).

GET / on a backend-only app answers 200 with a small status: JSON ({ "app": "api", "status": "ok", "api": "/api" }) for API clients, a small HTML page for a browser. For clients that authenticate with Authorization: Bearer and keep no cookies, csrf: false in app.config.ts stops the CSRF cookie from being issued at all (see Security model).

Static params for dynamic routes

Closes the real gap noted on the Core concepts page: ssg/isr on a dynamic route ([slug].tsx) now pre-render one static file per entry a getStaticParams() export returns.

// routes/posts/[slug].tsx
export const renderMode = "ssg";

export async function getStaticParams() {
  return [{ slug: "hello-world" }, { slug: "why-multi-app" }];
}

ssr/csr on a dynamic route are unaffected — they already had real params from the live request and never needed this.

DB reload-safety: a real dispose hook

The framework's own dev server can crash a native-addon DB driver (better-sqlite3, confirmed) if a stale connection is abandoned across a Vite SSR module reload — the fix is a globalThis singleton, documented in packages/backend/DATABASE.md. Devora.js also provides a real, generic complement: registerDisposable(import.meta.url, dispose) runs dispose right before Vite reloads that file, via a real Vite plugin hook (handleHotUpdate) — confirmed against a real dev server, not assumed. Use it to close the old connection cleanly instead of leaving it to a native finalizer; the globalThis singleton is still what prevents creating a redundant one in the meantime. Dev-only by construction — nothing to dispose in a production process that never reloads modules.