Backend capabilities
Devora.js deliberately ships no built-in validation library, logger, or rate limiter — the same "bring your own" line it draws for databases and auth providers. (Since 0.3.2 it does have built-in CORS for API routes and multipart parsing for page forms; both are covered below.) Every real project reaches for these early, so this page gives one recommended, working pattern for each. None of it is framework code: it's ordinary middleware and helpers you own, built on the primitives in Backend — apiRoute(), withMiddleware(), and defineMiddleware().
Where this code lives
Put shared helpers in packages/backend (e.g. packages/backend/validation.ts), and register every middleware in one file, packages/backend/middleware.ts — the same governance rule the framework already uses for third-party middleware. Routes opt in explicitly by importing from there; nothing runs for every route implicitly.
A shape worth knowing before the examples: a middleware wraps a handler and returns a new one.
type Middleware = (next: ApiRouteHandler) => ApiRouteHandler; // Runs left to right: logging, then cors, then rateLimit, then the handler. export const handler = withMiddleware(apiRoute(myHandler), requestLogger, cors, rateLimit);
Every example also uses one tiny helper for JSON responses — the same { message } envelope Devora uses for its own API errors:
// packages/backend/http.ts
import type { ApiResponse } from "@devorajs/core";
export function json(status: number, data?: unknown, headers: Record<string, string> = {}): ApiResponse {
return {
status,
headers: { "Content-Type": "application/json; charset=utf-8", ...headers },
body: data === undefined ? undefined : JSON.stringify(data),
};
}Validation (Zod)
req.body in an API route is the raw request bytes (a Buffer) on purpose — a webhook has to verify a signature against the exact bytes before trusting them. So validation is two steps: parse, then check the shape. Install Zod in your backend package (npm install zod -w packages/backend) and add one helper:
// packages/backend/validation.ts
import type { ApiRequest, ApiResponse } from "@devorajs/core";
import type { z, ZodTypeAny } from "zod";
import { json } from "./http.js";
type Parsed<S extends ZodTypeAny> = { ok: true; data: z.infer<S> } | { ok: false; response: ApiResponse };
function fromZod<S extends ZodTypeAny>(schema: S, raw: unknown): Parsed<S> {
const result = schema.safeParse(raw);
if (result.success) return { ok: true, data: result.data };
return {
ok: false,
response: json(400, {
message: "Invalid request",
issues: result.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })),
}),
};
}
export function parseJsonBody<S extends ZodTypeAny>(req: ApiRequest, schema: S): Parsed<S> {
let raw: unknown;
try {
raw = req.body.length === 0 ? {} : JSON.parse(req.body.toString("utf-8"));
} catch {
return { ok: false, response: json(400, { message: "Request body must be valid JSON" }) };
}
return fromZod(schema, raw);
}
export function parseQuery<S extends ZodTypeAny>(req: ApiRequest, schema: S): Parsed<S> {
const params = new URL(req.url, "http://localhost").searchParams;
return fromZod(schema, Object.fromEntries(params));
}Then in a route — the schema doubles as the TypeScript type of the parsed data:
// apps/dashboard/endpoints/bookings.ts
import { apiRoute } from "@devorajs/core";
import { z } from "zod";
import { parseJsonBody } from "@devorajs/backend/validation";
import { json } from "@devorajs/backend/http";
import { createBooking } from "@devorajs/backend/modules/bookings";
const CreateBooking = z.object({
shopId: z.string().uuid(),
date: z.coerce.date(),
notes: z.string().max(500).optional(),
});
export const methods = ["POST"];
export const handler = apiRoute(async (req, ctx) => {
ctx.requireAuth();
const parsed = parseJsonBody(req, CreateBooking);
if (!parsed.ok) return parsed.response; // 400 { message, issues }
const booking = await createBooking(parsed.data, ctx);
return json(201, booking);
});Validate at the trust boundary that's actually shared. If a serverFn in packages/backend is called from both an API route and a page action, validate inside the function itself (const input = CreateBooking.parse(rawInput)) so no caller can skip it. For a page action, run the same schema over Object.fromEntries(formData) — z.coerce handles the fact that every form value arrives as a string.
Return the 400, don't throw it
Returning parsed.response explicitly (rather than throwing) keeps the status and the issues list under your control, and reads clearly at the call site: a validation failure is an expected outcome, not an exception.
Logging
One request-logging middleware, registered once, gives every API route a consistent line per request: method, path, status, duration, and a request ID you can hand back to a client for support tickets.
// packages/backend/middleware.ts
import { randomUUID } from "node:crypto";
import { defineMiddleware } from "@devorajs/core";
function log(entry: Record<string, unknown>) {
// One JSON object per line — what log drains (Vercel, Netlify, Datadog, Loki...) parse best.
console.log(JSON.stringify({ time: new Date().toISOString(), ...entry }));
}
export const requestLogger = defineMiddleware((next) => async (req, ctx) => {
const started = performance.now();
const incoming = req.headers["x-request-id"];
const requestId = typeof incoming === "string" && /^[\w-]{1,64}$/.test(incoming) ? incoming : randomUUID();
// Pathname only: query strings can carry tokens, emails, and other things you don't want in logs.
const path = new URL(req.url, "http://localhost").pathname;
try {
const res = await next(req, ctx);
log({ level: res.status >= 500 ? "error" : "info", requestId, method: req.method, path,
status: res.status, ms: Math.round(performance.now() - started) });
return { ...res, headers: { ...res.headers, "X-Request-Id": requestId } };
} catch (err) {
log({ level: "error", requestId, method: req.method, path,
error: err instanceof Error ? err.message : String(err), ms: Math.round(performance.now() - started) });
throw err;
}
});- Never log
AuthorizationorCookieheaders, request bodies, or full URLs by default — a session ID in a log file is a working session for anyone who can read the logs. - Want levels, redaction, and pretty dev output? Swap
log()for pino (pino({ redact: ["req.headers.authorization", "req.headers.cookie"] })) — the middleware shape doesn't change. - Put
requestLoggerfirst inwithMiddleware(...)so it times and records everything inside it, including responses produced by CORS or rate limiting.
CORS
You only need CORS when a browser on a different origin calls your API (a separate web frontend, another app in the project on its own domain). Mobile apps and server-to-server calls aren't subject to it at all.
Since 0.3.2 the simplest option is built in: cors in the app's app.config.ts answers preflights before any route runs and echoes allowed origins, with config errors for an invalid origin or credentials with "*":
// apps/api/app.config.ts
export default defineApp({
cors: { origins: ["https://app.example.com", "https://*.example.com"], credentials: true },
});Use middleware instead when the rules differ per route. An explicit allowlist:
// packages/backend/middleware.ts
import { defineMiddleware } from "@devorajs/core";
const ALLOWED_ORIGINS = new Set(["https://app.example.com", "https://admin.example.com"]);
export const cors = defineMiddleware((next) => async (req, ctx) => {
const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
const allowed = origin !== undefined && ALLOWED_ORIGINS.has(origin);
const corsHeaders: Record<string, string> = allowed
? { "Access-Control-Allow-Origin": origin, "Vary": "Origin" }
: { "Vary": "Origin" };
if (req.method === "OPTIONS") {
// Preflight: answer it here, never run the handler.
return {
status: allowed ? 204 : 403,
headers: allowed
? { ...corsHeaders,
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Devora-Csrf",
"Access-Control-Max-Age": "600" }
: corsHeaders,
};
}
const res = await next(req, ctx);
return { ...res, headers: { ...corsHeaders, ...res.headers } };
});Add OPTIONS to the route's methods
If a route exports a methods allowlist, include "OPTIONS" in it. The framework rejects unlisted methods with a 405 before any middleware runs, so without it the preflight never reaches your CORS middleware.
Cookies change the rules
If a cross-origin browser client authenticates with the session cookie, you need Access-Control-Allow-Credentials: true and an exact origin — never * — and the cross-origin client is now also making cookie-authenticated state changes, so those routes must keep verifying CSRF. A client that authenticates with an Authorization: Bearer header carries no ambient credentials, which is why a Bearer-only API can safely use a broader origin policy. Prefer Bearer tokens for any genuinely cross-origin client.
Prefer the cors npm package? It works through fromExpressMiddleware(cors({ origin: [...] })) — see Backend, including its preflight support.
Rate limiting
Rate-limit anything expensive or abusable: login, OTP/password-reset requests, signups, and search. A fixed-window counter is enough for most apps:
// packages/backend/rateLimit.ts
import type { ApiRequest, Middleware, RequestContext } from "@devorajs/core";
import { json } from "./http.js";
export function rateLimit(opts: {
limit: number; // requests allowed...
windowMs: number; // ...per window
key: (req: ApiRequest, ctx: RequestContext) => string | undefined;
}): Middleware {
const hits = new Map<string, { count: number; resetAt: number }>();
return (next) => async (req, ctx) => {
const key = opts.key(req, ctx);
if (key === undefined) return next(req, ctx);
const now = Date.now();
let entry = hits.get(key);
if (!entry || now >= entry.resetAt) {
if (hits.size > 50_000) for (const [k, e] of hits) if (now >= e.resetAt) hits.delete(k);
entry = { count: 0, resetAt: now + opts.windowMs };
hits.set(key, entry);
}
entry.count += 1;
if (entry.count > opts.limit) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
return json(429, { message: "Too many requests" }, { "Retry-After": String(retryAfter) });
}
return next(req, ctx);
};
}
// apps/dashboard/endpoints/login.ts — 10 attempts per IP per 15 minutes
// ctx.ip: the client IP, read from X-Forwarded-For only when trustProxy is set (see below).
const loginLimit = rateLimit({ limit: 10, windowMs: 15 * 60_000, key: (_req, ctx) => ctx.ip });
export const handler = withMiddleware(apiRoute(login), requestLogger, loginLimit);
// Authenticated routes: key by user, not IP (many users can share one IP).
const perUser = rateLimit({ limit: 100, windowMs: 60_000,
key: (_req, ctx) => (ctx.session as { userId?: string } | undefined)?.userId });Two things to get right
Where the counters live. The Map above is per process. That's correct for a single devora start server, but on Vercel/Netlify each function instance has its own map (and a cold start resets it), so the limit becomes approximate at best. For serverless or multiple instances, keep the counters in a shared store — Redis, or a hosted limiter such as @upstash/ratelimit — behind the same middleware shape.
Where the IP comes from. ctx.ip is the TCP peer unless the app sets trustProxy in app.config.ts (true for one proxy, a number for a chain, e.g. 2 for a CDN in front of nginx). Then it's read from the right of X-Forwarded-For, skipping the trusted hops, so a client can't spoof it by sending the header itself. The generated Vercel and Netlify functions trust their platform's proxy by default. Don't hand-parse X-Forwarded-For: its leftmost entry is whatever the client sent.
File uploads
Pick the pattern by file size. Devora caps every request body at 10 MB (larger requests get a 413), and serverless platforms cap function request bodies lower still — a few MB (Vercel's limit is 4.5 MB). So:
- Small files (avatars, documents a few MB at most): a page form with
encType="multipart/form-data"— the route'sactionreceives file fields asFileobjects, nothing to set up (see Pages, forms and errors) — ormultipart/form-dataparsed in an API route, below. The 10 MB cap ismaxBodyBytesinapp.config.ts. - Anything larger, or anything on serverless: have the client upload straight to object storage with a presigned URL, and keep your API route as the part that authorizes and records the upload.
Small files: multipart in an API route
Node 20+ ships the standard Request.formData() parser, so no multipart library is needed — wrap the raw body in a Request and let it parse:
// apps/dashboard/endpoints/avatar.ts
import { randomUUID } from "node:crypto";
import { apiRoute, CSRF_HEADER_NAME } from "@devorajs/core";
import { json } from "@devorajs/backend/http";
import { storage } from "@devorajs/backend/storage"; // your S3/R2/GCS/disk client
const MAX_BYTES = 2 * 1024 * 1024;
// Check the file's real leading bytes — the client-supplied type and filename are just claims.
const SIGNATURES: Record<string, (b: Buffer) => boolean> = {
"image/png": (b) => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
"image/jpeg": (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
};
export const methods = ["POST"];
export const handler = apiRoute(async (req, ctx) => {
ctx.requireAuth();
const csrf = req.headers[CSRF_HEADER_NAME];
ctx.verifyCsrf(typeof csrf === "string" ? csrf : ""); // cookie-authenticated uploads need this
const headers = new Headers();
for (const [name, value] of Object.entries(req.headers)) {
if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(", ") : value);
}
let form: FormData;
try {
form = await new Request("http://localhost" + req.url, { method: "POST", headers, body: req.body }).formData();
} catch {
return json(400, { message: "Expected multipart/form-data" });
}
const file = form.get("avatar");
if (!(file instanceof File)) return json(400, { message: "avatar file is required" });
if (file.size > MAX_BYTES) return json(413, { message: "File too large (max 2 MB)" });
const bytes = Buffer.from(await file.arrayBuffer());
const type = Object.keys(SIGNATURES).find((t) => SIGNATURES[t]!(bytes));
if (!type) return json(415, { message: "Only PNG or JPEG images are accepted" });
const key = "avatars/" + randomUUID(); // never reuse the client's filename as a storage path
await storage.put(key, bytes, { contentType: type });
return json(201, { key });
});Large files: presigned direct-to-storage uploads
The file never passes through your server, so neither the 10 MB cap nor a platform's function limits apply, and a slow upload doesn't hold a server connection open. Your API route does the parts that need your rules:
POST /api/uploads— authenticate, validate the declared type and size (Zod works well here), pick the storage key yourself, and return a short-lived presignedPUTURL (e.g.getSignedUrlfrom@aws-sdk/s3-request-presigner, which also works with R2 and other S3-compatible stores). Sign the content type and length into the URL where your provider supports it, so the client can't upload something different.- The client
PUTs the file directly to that URL. POST /api/uploads/:key/complete— confirm the object exists (aHEADrequest against storage), check its real size/type, then record it in your database. Until this step, treat the upload as untrusted and unattached.
Serving what users upload
Serve user files from a separate domain or bucket, never from your app's own origin, and set Content-Disposition: attachment for anything that isn't an image you've verified. An uploaded HTML or SVG file served from your origin runs as your site.
Putting it together
// packages/backend/middleware.ts — requestLogger and cors (above) live here, plus:
export const authLimit = rateLimit({ limit: 10, windowMs: 15 * 60_000, key: (_req, ctx) => ctx.ip });
// apps/dashboard/endpoints/login.ts
import { apiRoute, withMiddleware } from "@devorajs/core";
import { requestLogger, cors, authLimit } from "@devorajs/backend/middleware";
export const methods = ["POST", "OPTIONS"];
export const handler = withMiddleware(apiRoute(login), requestLogger, cors, authLimit);Logging outermost (it records everything, including rejected requests), then CORS (so even a 429 carries the headers a cross-origin browser needs to read it), then rate limiting, then the handler with its own validation.