Configuration
ohne.config.ts and the core settings it holds.
ohne.config.ts at the root is what makes a directory an ohne project. The CLI, the servers, and codegen all recognize the project by this one file. It default-exports a defineConfig call, which adds type checking and autocomplete to a plain object:
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base'],
});Every key is optional and every key has a default, so this scaffold config is already complete. Layers document the keys they add, like auth and uploads.
Layers
A layer is an ohne project you install as a package and build on. Everything it ships becomes part of your app: routes, collections, messages, dashboard pages. Anything you define yourself wins.
Say you run several services, and each one needs the same API keys collection, the same roles, and the same audit-log middleware. Put them in one base layer and list it in every service. You fix a bug once, and every service gets the fix. If one service needs to log differently, it overrides just that middleware.
ohne ships its own content as a layer too. That is why the scaffold lists ohnejs/base: it brings users, sign-in, and the dashboard. A service on your base layer lists both:
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base', '@acme/service-base'],
});Later entries override earlier ones, and your project overrides them all. Defaults to []. The layers guide shows how to add a layer and build your own.
Directories
dirs sets where ohne reads each kind of file, relative to the layer's own root. codegen is the only one ohne writes to. It is a single directory for the whole stack, at your project's root:
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base'],
dirs: {
api: 'routes',
},
});codegen: '.ohne'- where generated.tsfiles land.api: 'api'- API route files.boot: 'boot'- boot files, run once at start.middleware: 'middleware'- middleware. Files in itsglobal/subdirectory run on every request.messages: 'messages'- message catalogs, one JSON file per language.collections: 'collections'- collection definitions.fields: 'fields'- custom field types.blocks: 'blocks'- block definitions.roles: 'roles'- role definitions.skills: 'skills'- skill definitions, the prompts the dashboard assistant runs on request.flows: 'flows'- flow definitions, the graphs of decisions and steps the assistant walks.migrations: 'migrations'- database migrations.commands: 'commands'- CLI commands.dashboard: 'dashboard'- dashboard pages and components.
dirs is never inherited. Each layer reads its own, so renaming your api directory to routes moves only your routes.
Disabling
disable lets you use a layer without taking everything it ships. It drops components after the layers are combined, so what you list here applies to anything in the stack, including your own:
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base'],
disable: {
routes: ['GET /admin/**'],
messages: ['dashboard.**'],
collections: ['Drafts'],
},
});routes- globs over route ids. A glob with a method prefix, like'GET /admin/**', matches only that method. Without one it matches every method. When you drop a collection's create, update, or delete route, an uploads route, or a bookmarks route, the dashboard hides the controls that call it.messages- globs over the dot-separated key, sodashboard.**drops the whole group. A dropped key vanishes from the catalog endpoint,useT, and the generatedKnownMessagestype.collections,fields,blocks,roles,skills,flows- exact names. A dropped collection or block vanishes from the schema and the generated types. A field still referencing a dropped field type fails at codegen.
The lists accumulate across layers: every layer's entries are combined, with duplicates removed. So a layer can drop components too, and you can always add more.
Content locales
collections.locales names the locales that records may hold, as BCP-47 tags. collections.defaultLocale must be one of them. When you make a field translatable, its existing values move to the default locale.
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base'],
collections: {
locales: ['en', 'de-AT', 'fr'],
defaultLocale: 'en',
},
});- They default to
['en']and'en'. - Tags are canonicalized for you, so
de-atbecomesde-AT.
Content locales are separate from the languages of your message catalogs. Translations covers marking fields and reading per locale.
Messages
messages.defaultLanguage (default 'en') is the language a request falls back to when its own language, and its parents, have no catalog entry:
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base'],
messages: {
defaultLanguage: 'de',
},
});The API server
api configures the HTTP server that ohne serve api runs:
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base'],
api: {
port: 3000,
basePath: '/api',
},
});Durations take milliseconds or a string like '30s'. Sizes take bytes or a string like '1mb'. false turns a limit off, or leaves Node's own default where one exists.
| Key | Default | What it does |
|---|---|---|
port | 9001 | The port the server listens on. |
host | - | The host to bind. Unset binds every interface. |
basePath | '' | Prefix for every route. With '/api', /authors is served at /api/authors, and the handler still sees /authors. |
handlerTimeout | '30s' | How long middleware and the handler may run before the server answers with 503. |
maxBodySize | '1mb' | Largest request body. A larger body is refused with 413. |
preStopDelay | false | Keeps serving after a shutdown signal, so a load balancer can deregister. |
shutdownTimeout | false | How long running requests may take to finish on shutdown, before they are cancelled. |
deadline | false | Deadline for every shutdown hook combined, the cleanup of cancelled requests included. |
waitUntilTimeout | false | How long a waitUntil promise may run after the response. |
headersTimeout | false | Wait for the complete request headers. Node's default is 60 seconds. |
requestTimeout | false | The whole request, headers and body. Node's default is 5 minutes. |
keepAliveTimeout | false | Idle keep-alive sockets between requests. Node's default is 5 seconds. |
maxConnections | false | Limit on concurrent sockets. |
maxHeaderSize | false | Largest request header block. Node's default is 16 KiB. |
trustProxy | [] | CIDR ranges of proxies allowed to set X-Forwarded-* headers. |
allowedHosts | [] | Hostnames the server answers to. Empty answers any host. |
rateLimitStore | 'memory' | Where rate limits count: each process, or a shared 'database'. |
rateLimitDatabase | - | The helper database the 'database' store counts in. |
handlerTimeout, maxBodySize, and waitUntilTimeout can also be set per route.
For production, deployment covers when to set these: for capacity, a graceful shutdown, and running behind a proxy.
The dashboard
dashboard configures the dashboard's server, run by ohne serve dashboard:
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base'],
dashboard: {
apiURL: 'https://api.example.com',
origin: 'https://admin.example.com',
},
});| Key | Default | What it does |
|---|---|---|
port | 9000 | The port the dashboard listens on. |
host | - | The host to bind. Unset binds every interface. |
apiURL | - | Absolute base URL of the API the browser calls, including any api.basePath. Unset, it is derived from api. |
origin | - | Absolute origin the browser reaches the dashboard at. The API's CORS accepts credentialed requests from it. Unset, it is derived from host and port. |
frameAncestors | - | Origins of the pages allowed to show the dashboard in an iframe. Unset, no page may frame it. |
menu | - | The sidebar nodes, keyed by id. Every layer patches them by id, so you can move, relabel, or hide what a layer declares. |
The defaults fit local development, where the browser reaches both servers directly. In production, give apiURL and origin the public URLs instead. Deployment covers the pair behind a proxy, where a wrong origin makes the browser block every dashboard request.
The database
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base'],
database: {
url: '.data/app.db',
helpers: { cache: ':memory:' },
},
});dialect- which dialect opens the database. Unset, the URL decides, so apostgres://URL picks Postgres.url: '.data/ohne.db'- where the main database lives, as a URL the dialect understands. For SQLite this is a file path, created if missing, or':memory:'for a temporary database.helpers: {}- additional databases keyed by name, reached withuseDatabase('name'). A helper holds no schema, only what you read and write. Any layer can contribute one. When two layers use the same name, the closer layer wins.sync.force: false- allow destructive schema syncs on every boot.
The database guide covers connections, transactions, and helper databases.
Query guards
query.guards overrides the limits on queries that arrive over HTTP, in the URL or a POST body. Queries you build in your own code are trusted and never checked:
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base'],
query: {
guards: { maxInLength: 500, maxPerPage: 100 },
},
});A limit you set replaces its default. The others keep theirs. Keep maxLimit at or above maxInLength, so a UUID in list gets back every row it names. Querying over HTTP lists every limit and its default.
The printer
printer controls terminal output:
import { defineConfig } from 'ohnejs';
export default defineConfig({
layers: ['ohnejs/base'],
printer: {
silent: true,
},
});silent: false-truedrops every print call.debug: false-trueprints debug output.
Env vars win
Some env vars override a config key whenever they are set, such as PORT over api.port and dashboard.port:
PORT=4000 npx ohne serve apiEnvironment variables lists each one and the full order.
Reading config
useConfig() returns the resolved config: your ohne.config.ts merged with every layer. Call it anywhere in app code:
import { useConfig } from 'ohnejs';
const config = useConfig();
config.collections.locales; // -> ['en']
config.api.basePath; // -> ''
config.disable.routes; // -> every layer's dropped globs, combinedEvery field with a default is always present, so none of the reads above need a fallback. Read it inside a computed or an effect to re-run when the config changes.
Own vs inherited keys
This matters when your project stacks layers. Config resolves per key, closest first: your value wins, and a layer's value fills in where you set nothing. Nested groups merge key by key, so setting api.handlerTimeout keeps a layer's api.basePath.
Some keys resolve differently:
- Accumulating - each
disablelist combines every layer's entries, without duplicates. - Own - never inherited. A layer's value applies to that layer alone. An own key you leave unset takes the framework default, even if a layer sets it. These are
dirs,printer,api.port,api.host,dashboard.port,dashboard.host,dashboard.apiURL,dashboard.origin,dashboard.frameAncestors,database.dialect,database.url, anddatabase.sync.force.
A base layer and your project both set api:
// the base layer
api: { basePath: '/v1', port: 4000 },
// your project
api: { handlerTimeout: '10s' },
// resolved for your project
api: { basePath: '/v1', handlerTimeout: '10s' },basePath fills in from the layer. port is an own key, so it stays with the layer and your server listens on the default 9001.
Own keys are a trust boundary. A dependency layer cannot move your ports, point you at its database, silence your printer, or force a destructive sync.
dashboard.menu is not an own key. It merges node by node, so a layer that ships dashboard pages can add its links, and you keep the last word on each one.