Core concepts
Multi-app architecture
A devora.js project can declare more than one app — a marketing site, a dashboard, an admin panel — as siblings under apps/, all registered in one devora.config.ts at the project root. This isn't a monorepo tool bolted on after the fact; it's the framework's headline feature. Each app has its own routes/ directory and its own app.config.ts, but they share one packages/core for common logic and, by default, one packages/backend for server functions and the DB client. Each app can be built and deployed independently (devora build --app=admin) or all together (devora build), and each is served from its own domain.
Auth: shared, isolated, or none
Auth is a per-app setting, not a single project-wide switch. Three modes:
shared — the default. All apps set to shared use one project-wide session cookie, so a user logs in once and that session is valid across marketing, dashboard, and admin alike.
isolated — this app gets its own session cookie name and can be given its own secret (DEVORA_SESSION_SECRET_<APPNAME>). Useful when one app genuinely needs a separate identity provider or session boundary — an admin panel is the typical case.
none — disables the session/cookie/CSRF carrier entirely for that app. This isn't the same as inheriting the project default; it's a real third state, set explicitly. It exists because an app with no login route anywhere (a marketing site) shouldn't need to configure a session secret it will never use. Calling a session method (setSession(), requireAuth(), etc., on the request context) inside a none app throws a clear error rather than silently doing nothing — and the build step checks for this upfront, so a misconfigured route fails at devora build time, not when a real request hits it.
Leaving auth unset on an app means "inherit the project's shared.auth default" — set it to "none" explicitly if that's genuinely what you want, so it's unambiguous from the config file alone which apps have sessions enabled at all.
The shared backend pattern
By default, every app calls into one shared backend (packages/backend) for server functions and the DB layer — a single source of truth for business logic, callable directly from any app's routes with no hand-written fetch + API route boilerplate:
// packages/backend/functions/settings.ts
export const updateSettings = serverFn(async (input, ctx) => {
// ctx exposes requireAuth() to gate this on a valid session
return db.settings.update(input);
});
// apps/dashboard/routes/settings.tsx
import { updateSettings } from "@devorajs/backend/settings";
// call it directly — same function, same DB, same logic every app usesAn app can still define a function locally, inside its own routes/, when something is genuinely app-specific — an admin-only bulk-import function nobody else needs, for example. This is an opt-out per function, not a project-wide switch: most projects use the shared backend for almost everything and only reach for an app-local function occasionally.
File-based routing
Each app routes itself from its own routes/ directory — a file's path under routes/ becomes its URL path, and routes/index.tsx is that app's home page. A route file exports whatever it needs explicitly: loader for server-side data, a default-exported component for the UI, and an optional action for mutations (form posts). Nothing is inferred beyond the path itself — there's no special meaning attached to a filename beyond where it sits in the tree.
A dynamic segment — routes/users/[id].tsx matches /users/123, with the value available as ctx.params.id in loader/action — is supported for ssr and csr routes. A static route at the same depth always wins over a dynamic one (routes/users/new.tsx beats routes/users/[id].tsx for /users/new). ssg/isr don't support dynamic routes yet — there's no API yet for a route to declare which concrete values to pre-render, so the build fails with a clear error rather than mis-building; use ssr/csr for a dynamic route instead.