Configuration
A QUESTPIE app is configured by a handful of files, each owning one concern. Which file owns which setting, what resolves from the environment on its own, and what a setting you never write falls back to.
You have a database URL, a signing secret, an email adapter and a list of locales. Each one goes somewhere different. Here is the map.
The files
They all sit under src/questpie/server/. Codegen reads them and writes
.generated/, which is what actually boots.
| File | Owns | Factory |
|---|---|---|
questpie.config.ts | Database, adapters, secret, storage | runtimeConfig() |
config/app.ts | Locales, default access, global hooks, per-request context | appConfig() |
config/auth.ts | Better Auth options | authConfig() |
config/<name>.ts | One plugin's config, such as config/admin.ts | the plugin's own |
modules.ts | Pre-built modules you depend on | none, a plain array |
env.ts | Env var schema and validation | env() |
Only questpie.config.ts is required. A scaffolded project ships that,
modules.ts, and two or three files under config/. It ships no
config/app.ts at all.
The one you must write
import { ConsoleAdapter } from "questpie/adapters/console";
import { runtimeConfig } from "questpie/app";
import env from "./env"; // declared and validated in env.ts
export default runtimeConfig({
app: { url: env.APP_URL },
db: { url: env.DATABASE_URL },
email: { adapter: new ConsoleAdapter() },
});Then build the app surface.
questpie generateruntimeConfig() holds infrastructure and nothing else. Collections, globals,
routes and jobs come from their own directories. There is no key here for them.
`email.adapter` is not optional
The mailer starts with every app. Leave email out and startup throws
QUESTPIE: 'email.adapter' is required before the first request lands. Use
ConsoleAdapter in development.
What resolves without you
Leave out app, db, secret or storage and runtimeConfig() reads the
environment. Your value wins, then the QUESTPIE_* name, then the standard one.
| Field | QUESTPIE Cloud | Standard | If neither |
|---|---|---|---|
app.url | QUESTPIE_APP_URL | APP_URL | http://localhost:3000 |
db | QUESTPIE_DB | DATABASE_URL | throws |
secret | QUESTPIE_SECRET | BETTER_AUTH_SECRET | undefined |
storage | QUESTPIE_STORAGE_* | none | local ./uploads |
db is the one with no default. If none of the three has it, runtimeConfig()
throws where you call it. On QUESTPIE Cloud the file collapses to what nothing
can guess.
export default runtimeConfig({
email: { adapter: new ConsoleAdapter() },
});The rest of the slots
Every other key picks an adapter or flips a switch. All of them are optional.
| Key | Picks |
|---|---|
queue | The job backend. Required once you declare a job. |
search | The index. Postgres full-text when you skip it. |
kv | The key-value store. In-memory when you skip it. |
realtime | The live transport. Always on. This tunes it. |
storage | Where uploaded bytes land. |
logger, observability | Log format, traces and metrics. |
executor | Sandboxed code execution. Off unless you set it. |
autoMigrate, autoSeed | Whether boot runs migrations and seeds. |
Runtime options has the full table, the four
shapes db accepts, and the storage keys.
config/app.ts
One file, four app-wide concerns. Every key is optional and the file itself is optional.
| Key | Decides |
|---|---|
locale | Which content locales exist and which one is the default. |
access | The rule a collection falls back to when it declares none. |
hooks | Callbacks that run across every collection or global. |
context | What each request carries, resolved once, before anything. |
context is the one worth learning first. It runs once per HTTP request and its
return travels flat into every access rule, hook, route and getContext() call.
import { appConfig } from "questpie/app";
export default appConfig({
access: { read: true },
context: async ({ request }) => ({
tenantId: request.headers.get("x-tenant-id"),
}),
});Calls inside the resolver run in system mode, so access rules are bypassed. The resolver is the trusted derivation step.
`access` replaces, it does not merge
Set the key and a module's whole map goes. Write read: true alone and
create, update and delete fall back to "require a session", not to what
the module wanted.
App config covers all four keys.
config/auth.ts
The whole file is Better Auth options. Anything Better Auth accepts goes here.
import { admin, bearer } from "better-auth/plugins";
import { authConfig } from "questpie/app";
export default authConfig({
plugins: [admin(), bearer()],
emailAndPassword: { enabled: true, requireEmailVerification: false },
});baseURL comes from app.url and secret from runtimeConfig(), so you do not
repeat them. Setting either here still wins, because your options are spread
last.
Your session type is built from the whole module tree's auth config intersected
with this file. A module that adds a Better Auth plugin widens AppSessionUser
for you. If a field is missing, run questpie generate before you cast.
modules.ts
A plain array of the pre-built modules you depend on.
import { adminModule } from "@questpie/admin/modules/admin";
import { openApiModule } from "@questpie/openapi";
export default [adminModule, openApiModule] as const;Codegen reads this file first, in its own pass, so each module's file conventions are registered before discovery runs. The tree is walked dependencies first, and two modules with the same name collapse to the last one. See Modules.
Plugins bring their own file
config/app.ts and config/auth.ts are not special. Each config/<name>.ts
becomes config.<name>, and a plugin claims one with a single discover pattern.
That is how @questpie/admin gets config/admin.ts.
Plugin config files shows how to claim one.
Where each topic lives
| Topic | Page |
|---|---|
Every runtimeConfig() key, db, storage | Runtime options |
| Locales, default access, global hooks, context | App config |
Claiming a config/<name>.ts from a plugin | Plugin config files |
| Declaring and validating env vars | Environment |
| What the generator reads and writes | Codegen |
Next
Environment is where env.APP_URL and
env.DATABASE_URL in the example above come from.