Guides

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 Authorization or Cookie headers, 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 requestLogger first in withMiddleware(...) 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's action receives file fields as File objects, nothing to set up (see Pages, forms and errors) — or multipart/form-data parsed in an API route, below. The 10 MB cap is maxBodyBytes in app.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:

  1. POST /api/uploads — authenticate, validate the declared type and size (Zod works well here), pick the storage key yourself, and return a short-lived presigned PUT URL (e.g. getSignedUrl from @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.
  2. The client PUTs the file directly to that URL.
  3. POST /api/uploads/:key/complete — confirm the object exists (a HEAD request 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.