QUESTPIE
Concepts

Configuration

Configuration is how you wire up a QUESTPIE app, runtime infrastructure in questpie.config.ts, app-level behavior in config/app.ts, auth in config/auth.ts, and the modules you depend on in modules.ts. Each is one declarative file codegen discovers and stitches together.

A QUESTPIE app is configured by a handful of convention files, each owning one concern. questpie.config.ts declares runtime infrastructure (database, adapters, secret, codegen plugins). config/app.ts sets locale, default access, global hooks, and a per-request context resolver. config/auth.ts carries your Better Auth options. modules.ts lists the pre-built modules you depend on. You never assemble these by hand, codegen discovers each file, merges it across every module, and emits the typed .generated/ app. Edit a file, run questpie generate, and the whole app moves with it.

What it does

  • Splits runtime from schema. questpie.config.ts holds only infrastructure and plugins; your entities (collections, globals, routes, jobs) come from file convention. The two never tangle.
  • Auto-resolves infra from the environment. runtimeConfig() reads app.url, db.url, secret, and storage from QUESTPIE_* / standard env vars when you omit them, zero-config on QUESTPIE Cloud, explicit overrides anywhere else.
  • One typed factory per concern. runtimeConfig(), appConfig(), and authConfig() are identity factories that exist purely so TypeScript infers your config shape (and, for auth, your session type) end to end.
  • A per-request context resolver. appConfig({ context }) runs once per request and its return travels flat into every access rule, hook, route, and getContext(), the seam for multi-tenancy and derived request state.
  • Plugin-extensible config bucket. Any plugin can claim its own config/<name>.ts file (admin → config/admin.ts, OpenAPI → config/openapi.ts) by declaring one discover pattern and augmenting AppStateConfig. No framework code changes.
  • Modules are config you add. modules.ts default-exports an array of pre-built modules; each can contribute collections, globals, jobs, messages, and even codegen plugins, auto-wired, nothing to register imperatively.

Quick start

A new project ships these four files under src/questpie/server/. Together they are a complete, bootable configuration.

src/questpie/server/questpie.config.ts
import { runtimeConfig } from "questpie/app";
import { ConsoleAdapter } from "questpie/adapters/console";

import env from "./env"; // declared + validated in env.ts, see Environment

// Runtime infrastructure + codegen plugins only, no entities here.
export default runtimeConfig({
	app: { url: env.APP_URL },
	db: { url: env.DATABASE_URL },
	secret: env.BETTER_AUTH_SECRET,
	email: { adapter: new ConsoleAdapter() },
});
src/questpie/server/config/app.ts
import { appConfig } from "questpie/app";

// Discovered as config/app.ts → AppStateConfig.app.
export default appConfig({
	locale: {
		locales: [
			{ code: "en", label: "English", fallback: true },
			{ code: "sk", label: "Slovenčina" },
		],
		defaultLocale: "en",
	},
});
src/questpie/server/config/auth.ts
import { authConfig } from "questpie/app";

// Discovered as config/auth.ts → AppStateConfig.auth (Better Auth options).
export default authConfig({
	emailAndPassword: { enabled: true, requireEmailVerification: false },
});
src/questpie/server/modules.ts
import { adminModule } from "@questpie/admin/modules/admin";
import { openApiModule } from "@questpie/openapi";

// Pre-built modules this project depends on.
const modules = [adminModule, openApiModule] as const;
export default modules;

Then regenerate the typed app:

questpie generate   # discovers every config file, merges modules, emits .generated/index.ts

Import the factories from `questpie/app` (or `questpie`)

runtimeConfig, module, appConfig, and authConfig are all exported from both questpie and the lighter questpie/app barrel. The starter templates and examples use questpie/app. The CLI factories, config() and packageConfig(), live in questpie/cli instead.

How the files connect

There is one runtime file and a set of discovered convention files. Codegen reads them all and emits .generated/index.ts, which is what actually boots.

FileOwnsDiscovered asFactory
questpie.config.tsdb, adapters, secret, storage, codegen pluginsthe runtime config (root)runtimeConfig()
config/app.tslocale, default access, global hooks, context resolversingle → config.appappConfig()
config/auth.tsBetter Auth optionssingle → config.authauthConfig()
config/<name>.tsany plugin's composite configsingle → config.<name>plugin's factory
modules.tspre-built module dependenciesthe module list (pre-pass)none (plain array)
env.tsenv var schema + validationthe env definitionenv(), see Environment

questpie.config.ts carries only the runtime half. The config/*.ts files are file-convention singles: codegen discovers each one and drops the whole file under a key in the config bucket (AppStateConfig), merging that key across every module before it reaches the app. modules.ts is read first, in a dedicated pre-pass, so the modules' own files and codegen plugins are folded in before the rest of discovery runs.

questpie generate   # one shot: pre-pass modules.ts, discover all files, emit .generated/
questpie dev        # the same, in watch mode, regenerates on file add/remove

`questpie.config.ts` needs `.generated/index.ts` to exist

The CLI's questpie.config.ts is in the "new format", it carries app.url but is not itself a built app. On a CLI command it auto-resolves .generated/index.ts for the real app instance, and errors with "Run questpie generate first" if that file is missing. Generate before you migrate, seed, or push.

runtimeConfig, runtime infrastructure

runtimeConfig() is the default export of questpie.config.ts. It is an identity factory: it returns your config (after resolving app/db/secret/storage from the environment) so TypeScript keeps the exact shape. This is where you register adapters and codegen plugins, never entities.

src/questpie/server/questpie.config.ts
import { runtimeConfig } from "questpie/app";
import { ConsoleAdapter } from "questpie/adapters/console";
import { pgBossAdapter } from "questpie/adapters/pg-boss";

import { messages } from "@/questpie/server/i18n";
import env from "./env";

export default runtimeConfig({
	app: { url: env.APP_URL },
	db: { url: env.DATABASE_URL },
	secret: env.BETTER_AUTH_SECRET,
	storage: { basePath: "/api" },
	translations: { messages },
	email: { adapter: new ConsoleAdapter({ logHtml: false }) },
	queue: { adapter: pgBossAdapter({ connectionString: env.DATABASE_URL }) },
});

Auto-resolution from the environment

app and db are optional in the input, when you omit a field, runtimeConfig() resolves it from env, in this order: your explicit value, then QUESTPIE_* (injected by QUESTPIE Cloud), then the standard fallback, then a default.

FieldQUESTPIE Cloud envStandard fallbackDefault
app.urlQUESTPIE_APP_URLAPP_URLhttp://localhost:3000
db.urlQUESTPIE_DBDATABASE_URL(throws if unresolvable)
secretQUESTPIE_SECRETBETTER_AUTH_SECRETundefined
storageQUESTPIE_STORAGE_*nonelocal ./uploads

So on QUESTPIE Cloud the whole file can collapse to just your plugins:

export default runtimeConfig({
	plugins: [], // app/db/secret/storage all auto-resolved from QUESTPIE_* env
});

When QUESTPIE_STORAGE_ENDPOINT, QUESTPIE_STORAGE_BUCKET, QUESTPIE_STORAGE_ACCESS_KEY, and QUESTPIE_STORAGE_SECRET_KEY are all set, an S3-compatible Files SDK adapter is auto-configured. The adapter is lazy, the AWS SDK import is deferred to the first storage operation, so you must install all four peer deps: @aws-sdk/client-s3, @aws-sdk/lib-storage, @aws-sdk/s3-presigned-post, and @aws-sdk/s3-request-presigner.

`db` must resolve from somewhere

app.url falls back to http://localhost:3000, but db.url has no default, if it can't be resolved from your value, QUESTPIE_DB, or DATABASE_URL, runtimeConfig() throws at construction. Always provide a database.

Options reference

Everything below app/db is optional. The full RuntimeConfigInput shape:

KeyTypePurpose
app{ url: string }Public app URL (auto-resolved if omitted).
dbDbConfigDatabase connection (auto-resolved if omitted).
secretstringSecret for signing tokens / signed file URLs.
pluginsCodegenPlugin[]Codegen plugins, extend file discovery + generated output.
storageStorageConfigFile storage: { location } (local) or { adapter } (Files SDK).
emailMailerConfig{ adapter }, e.g. ConsoleAdapter, SmtpAdapter.
queue{ adapter: QueueAdapter }Background job queue (e.g. pgBossAdapter).
searchSearchAdapterFull-text / vector search (defaults to Postgres search).
realtimetrue | RealtimeConfigRealtime transport. Use true for defaults, or an object for adapter/options.
kvKVConfigKey-value store (defaults to in-memory).
executorExecutorConfigSandboxed / trusted code execution (opt-in; disabled by default).
loggerLoggerConfigLogger configuration.
translationsTranslationsConfigBackend i18n messages ({ messages }).
autoMigratebooleanRun migrations on startup.
autoSeedboolean | SeedCategory | SeedCategory[]Run seeds on startup.
cli{ migrations?, seeds? }Override generated-migration / seed directories.

The input type is RuntimeConfigInput at packages/questpie/src/server/config/module-types.ts:407, defined as Partial<Pick<RuntimeConfig, "app" | "db">> & Omit<RuntimeConfig, "app" | "db">, i.e. the resolved RuntimeConfig interface (:308, where every adapter type lives) with app and db made optional. DbConfig itself is a union, { url }, { pglite }, { drizzle } (a pre-built client, for Neon / Vercel Postgres / Hyperdrive), or { create } (a lazy factory, preferred for Cloudflare Workers).

`plugins` is for codegen plugins, not modules

runtimeConfig({plugins}) registers codegen plugins (the thing that adds file-convention discovery and generated output). Module dependencies go in modules.ts instead, and a module can carry its own codegen plugin, so most apps never touch plugins directly. The adapters (queue, search, KV, …) each have their own page under Infrastructure.

The adapter and storage wiring (StorageConfig, the queue/search/realtime/kv/executor adapters) each have a dedicated page; this page covers the config file, not every adapter.

config/app.ts, app-level behavior

config/app.ts default-exports appConfig(...). It consolidates four app-wide concerns into one file: content locale, default access rules, global hooks, and a per-request context resolver. Codegen discovers it as a single and stores the whole file under config.app.

src/questpie/server/config/app.ts
import { appConfig } from "questpie/app";

export default appConfig({
	locale: {
		locales: [
			{ code: "en", label: "English", fallback: true, flagCountryCode: "us" },
			{ code: "sk", label: "Slovenčina" },
		],
		defaultLocale: "en",
	},
	access: { read: true }, // app-wide default; collections can override
	context: async ({ request, session }) => ({
		role: session?.user?.role ?? "guest",
	}),
});

AppConfigInput is { locale?, access?, hooks?, context? }. Every key is optional.

locale

LocaleConfig configures the content-localization (i18n) layer that backs localized fields:

locale: {
  locales: [
    { code: "en", label: "English", fallback: true },
    { code: "sk", label: "Slovenčina" },
  ],
  defaultLocale: "en",
  fallbacks: { "en-GB": "en" }, // optional per-locale fallback mapping
}

locales is a static array (or an async function returning one); defaultLocale is the code used when a request specifies none; fallbacks maps a locale to the code to fall back to.

access, app-wide default rules

access is the fallback access map applied when a collection or global doesn't declare its own rule for an operation. It mirrors collection access, read, create, update, delete, transition, serve, introspect, each a boolean or a function of the request context that may return a where object to filter. The per-collection Access control chain resolves to these defaults last.

access: {
  read: true,                                   // public read by default
  create: ({ session }) => Boolean(session),    // signed-in users can create
}

The app-level access context is a leaner seam

The function context here is ResolvedAppDefaultAccessContext (db, session, collections, …), not the fully augmented AccessContext that per-collection .access() rules get. This is deliberate: routing app-level rules through the full augmented context would create a type cycle in the generated app. Use collection-level .access() when you need the richest typed context.

hooks, global hooks

hooks registers hooks that run across every collection or global, keyed by target. Use it to apply a cross-cutting concern (audit, search reindexing) without touching each definition:

hooks: {
  collections: {
    include: ["posts", "pages"],                          // optional, defaults to all collections
    afterChange: ({ collection, data, operation }) => { /* runs across collections */ },
  },
  globals: {
    afterChange: ({ global, data }) => { /* runs across globals */ },
  },
}

The shape is GlobalHooksInput, { collections?: GlobalCollectionHookEntry; globals?: GlobalGlobalHookEntry }. Each value is a single hook-entry object (not an array): GlobalCollectionHookEntry is { include?, exclude?, beforeChange?, afterChange?, beforeDelete?, afterDelete?, beforeTransition?, afterTransition? }, and include/exclude scope it to specific slugs. The framework concatenates these entries across modules internally. Per-entity hooks still live on the collection/global builder, see Hooks.

`collections` / `globals` each take one entry object, not an array

A natural mistake is collections: [{ afterChange }], but the public GlobalHooksInput type rejects an array; collections is a single GlobalCollectionHookEntry. The array form ({ collections: GlobalCollectionHookEntry[] }) is the framework's internal accumulator (GlobalHooksState), never the input. To register several hooks, put multiple lifecycle keys on the one object ({ beforeChange, afterChange, afterDelete }).

context, the per-request context resolver

This is the most powerful knob in config/app.ts. context runs once per HTTP request; the object it returns is merged flat into the request context and reaches every access rule, hook, route handler, field-access rule, and getContext() call. It's the canonical seam for multi-tenancy and derived request state.

context: async ({ request, session, collections }) => {
  const tenantId = request.headers.get("x-tenant-id");

  if (tenantId && session?.user) {
    const member = await collections.tenant_members.findOne({
      where: { tenant: tenantId, user: session.user.id },
    });
    if (!member) throw new Error("No access to this tenant");
  }

  return { tenantId }; // now available as ctx.tenantId everywhere downstream
},

The resolver receives { request, session, db } plus the full system-mode service surface (collections, globals, kv, queue, logger, t, your services), typed via the codegen-emitted Questpie.ContextResolverContext. Calls inside the resolver run in system mode (access rules bypassed): the resolver is the trusted derivation step.

Annotate (or directly return) the resolver's object, don't return a bare primitive or only null

The resolver's return type is what drives the typed context extension downstream, so it must resolve to an object. appConfig() rejects a resolver that returns only a primitive (async () => "x") or only null (async () => null) at the call site. The common session ? { role } : null shape passes because it has an object arm. Because the return type is load-bearing, prefer returning an explicit object literal over an inferred-then-narrowed value.

Why `access` and `hooks` vanish from `appConfig()`'s return type

appConfig() is identity at runtime, but its return type erases access and hooks to opaque storage and keeps only locale and context. That's intentional: their function parameter types embed the merged AppContext, and riding them back into the generated index would collapse the whole augmentation (TS2456). Both are still fully typed at the call site, you just can't read them back off typeof appConfigFile.

config/auth.ts, authentication

config/auth.ts default-exports authConfig(...), wrapping your Better Auth options. AuthConfig is exactly BetterAuthOptions, so anything Better Auth accepts goes here. Codegen discovers it as a single and stores it under config.auth.

src/questpie/server/config/auth.ts
import { authConfig } from "questpie/app";

export default authConfig({
	emailAndPassword: { enabled: true, requireEmailVerification: false },
	socialProviders: {
		google: {
			clientId: process.env.GOOGLE_CLIENT_ID!,
			clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
		},
	},
});

authConfig() does one extra thing beyond identity: it recovers the inferred session shape from your options and threads it through a type-only channel, so your session type flows into the rest of the app without you exporting an unnameable plugin-instantiated type. Runtime values like secret and baseURL come from runtimeConfig() / env, don't duplicate them here.

Session typing comes from merged auth

The generated session type is derived from the full merged config.auth, not only from the local config/auth.ts file. Codegen folds auth config through the entire module tree, including nested modules such as adminModule -> starterModule, then intersects the app-local auth config on top.

That means module-provided Better Auth plugins must surface in generated types. In an admin app, the starter module contributes admin() and bearer(), so session.user.role should be typed in routes, hooks, access rules, services, and AppSessionUser.

import type { AppSessionUser } from "#questpie";

type Role = AppSessionUser["role"];

Keep admin apps explicit anyway:

src/questpie/server/config/auth.ts
import { admin, bearer } from "better-auth/plugins";
import { authConfig } from "questpie/app";

export default authConfig({
	plugins: [admin(), bearer()],
	emailAndPassword: { enabled: true, requireEmailVerification: false },
});

If session.user.role is not typed, do not cast it. Check modules.ts, config/auth.ts, and the generated src/questpie/server/.generated/context.gen.ts, then run questpie generate.

The config/*.ts convention (extensibility)

config/app.ts and config/auth.ts aren't special-cased, they're two instances of a general pattern: each config/<name>.ts file maps to one key in the config bucket (AppStateConfig). The whole file becomes config.<name>, and that key is merged per sub-key across every module. This is how plugins add composite config without forking the framework:

  • @questpie/admin claims config/admin.tsconfig.admin
  • @questpie/openapi claims config/openapi.tsconfig.openapi

To add your own config file from a plugin, declare one discover pattern and augment the interface:

// in your codegen plugin's target contribution
discover: {
  myPluginConfig: { pattern: "config/my-plugin.ts", configKey: "myPlugin" },
}
// in your plugin's types, make the key typed and discoverable
declare module "questpie" {
	interface AppStateConfig {
		myPlugin?: MyPluginConfig;
	}
}

Now users add config/my-plugin.ts (default-exporting your config), codegen folds it into config.myPlugin, and modules contributing the same key are merged per sub-key. AppStateConfig is { app?, auth? } at the core; everything else is augmentation.

Use `configKey` for config files

A discover pattern's configKey emits the whole file as one entry under the config bucket: one file maps to one config key.

modules.ts, module dependencies

modules.ts declares the pre-built modules your app depends on. It default-exports an array of module objects, conventionally as const:

src/questpie/server/modules.ts
import { adminModule } from "@questpie/admin/modules/admin";
import { auditModule } from "@questpie/admin/modules/audit";
import mcpModule from "@questpie/mcp";
import { openApiModule } from "@questpie/openapi";

export default [adminModule, auditModule, mcpModule, openApiModule] as const;

Codegen reads modules.ts in a dedicated pre-pass before the rest of discovery: it walks the tree depth-first (dependencies before dependents), extracts each module's codegen plugin (so the module's own file conventions get registered), and merges its contributed entities, collections, globals, jobs, routes, services, migrations, seeds, messages, and config, into your app. Duplicate modules (by name) are de-duplicated, last occurrence wins.

A module is a plain data object, no factory functions inside. You author one with the module() identity factory:

import { module, collection } from "questpie/app";

export const blogModule = module({
  name: "blog",
  collections: { posts: collection("posts")./* … */ },
  messages: { en: { "blog.published": "Published" } },
});

A module's `plugin` is codegen-only, never merged at runtime

The plugin key on a module is extracted by codegen and excluded from the runtime module merge. That's how a module package (@questpie/admin, @questpie/openapi) ships its file conventions without you registering anything in questpie.config.ts, adding the module to modules.ts is enough.

The full module surface (ModuleDefinition), how modules contribute each category, and the depth-first merge are covered in the Extend → Modules section. The full module() and ModuleDefinition reference lives at packages/questpie/src/server/config/create-app.ts:58 and module-types.ts:94.

CLI config

The root questpie.config.ts the CLI loads is normally just a re-export of your server config. Codegen resolves the built app from .generated/index.ts:

questpie.config.ts (project root)
// Re-export the server config; the CLI resolves .generated/index.ts for the app.
export { default } from "./src/questpie/server/questpie.config";

create-questpie scaffolds this shape. CLI settings such as migration and seed directories live in the server config when you need to override the defaults.

Importing `questpie/cli` must not run a command

The CLI program is side-effect-free on import, command parsing is guarded by import.meta.main. This matters because package config files import questpie/cli for packageConfig, and a stray src-vs-dist double instance running a command could corrupt .generated/.

The static-module pattern (packageConfig)

If you're building an npm package that ships modules (the way @questpie/admin does), you don't hand-write a .generated/module.ts, you describe the package once with packageConfig() and let questpie generate build a static module per subdirectory. This is dev-only config: it never ships, only the generated module.ts files do.

questpie.config.ts (in a module package)
import { packageConfig } from "questpie/cli";
import { myPlugin } from "./src/server/plugin.js";

export default packageConfig({
	modulesDir: "src/server/modules", // each subdir → one module
	modulePrefix: "questpie", // dir "admin" → module "questpie-admin"
	plugins: [myPlugin()], // codegen plugins shared across all modules
});

In package mode, questpie generate scans modulesDir/*, derives each module's name as ${modulePrefix}-${dirName}, and runs codegen in module mode per subdirectory (emitting .generated/module.ts, plus registries.ts when there are type augmentations). PackageConfig is { modulesDir, modulePrefix?, plugins? } (modulePrefix defaults from package.json name).

This is the foundation of the QUESTPIE extensibility principle: the framework's own built-in modules use the exact same packageConfig + config/*.ts conventions a userland module would, there are no privileged internal APIs. The core itself is configured with packageConfig({ modulesDir: "src/server/modules", modulePrefix: "questpie" }). Building a publishable module + plugin is covered in the Extend → Build a plugin section.

CLI commands

The configuration files only do something once codegen reads them. The relevant commands:

CommandDoes
questpie generateDiscover all config files, run the modules.ts pre-pass, emit .generated/. Run after any config change.
questpie devThe same in watch mode, regenerates on file add/remove (content edits are skipped; typeof import is stable).
questpie pushPush the current schema to the dev database (no migration).
questpie migrate:createGenerate a migration from the current schema (production path).

questpie generate auto-detects the config mode from the file shape (root-app vs module vs packageConfig) and sets QUESTPIE_SKIP_ENV_VALIDATION=1 while it imports your code (codegen must run without a populated env).

Watch mode regenerates on structural change only

questpie dev only re-runs codegen when a file is added or removed (or a config file changes), editing the contents of an existing collection/config file is skipped, because the generated app references it by typeof import(...), which is stable. If a change isn't picked up, it's structural: add/rename/delete is what triggers a regen.

TypeScript

The config factories give you typed access to every config shape, import them from questpie:

import type {
	RuntimeConfig, // resolved questpie.config.ts shape
	RuntimeConfigInput, // its input (app/db optional)
	AppConfigInput, // config/app.ts input ({ locale, access, hooks, context })
	AppStateConfig, // the plugin-extensible config bucket
	ContextResolver, // the appConfig({ context }) function type
	LocaleConfig,
	ModuleDefinition,
} from "questpie";

import type { TypedAuthConfig } from "questpie/app"; // authConfig() return

AppStateConfig is the interface plugins augment to add their own config/<name>.ts keys; ContextResolver<T> is generic over the object your resolver returns. The CLI types (QuestpieConfigFile, QuestpieCliConfig, PackageConfig) come from questpie/cli. Codegen plugin types (CodegenPlugin, CategoryDeclaration, DiscoverPattern, …) come from questpie/codegen.

  • Getting started, scaffold a project where these files are wired for you.
  • Collections, the entities runtimeConfig deliberately keeps out of questpie.config.ts.
  • Access control, how config/app.ts's access defaults sit at the bottom of the per-collection rule chain.
  • Hooks, per-entity hooks; config/app.ts's hooks are the global counterpart.
  • Environment, env.ts / env(), boot validation, and server-vs-client vars that feed runtimeConfig.
  • Runnable example: examples/toy-factory-backend, a complete questpie.config.ts + config/app.ts + config/auth.ts + modules.ts set.

On this page