Modules
A module is a plain object holding collections, routes, jobs and config. Add it to modules.ts and the whole feature arrives typed, in one line. QUESTPIE ships its own batteries this way, and you publish yours the same way.
Every app has a modules.ts. Codegen throws without one. This page is what
goes in that file, and what to write when the feature you want is your own.
Install one
modules.ts sits beside your collections/ directory. It default-exports an
array. Import a module and put it in.
import { adminModule } from "@questpie/admin/modules/admin";
import { openApiModule } from "@questpie/openapi";
export default [adminModule, openApiModule] as const;Then regenerate.
questpie generateThat is the whole install. You get the admin panel, the auth collections behind
it, and the Scalar reference at /api/docs. You did not touch
questpie.config.ts. There is no second list of plugins to keep in sync.
What QUESTPIE ships
| Module | Import | Brings |
|---|---|---|
adminModule | @questpie/admin/modules/admin | Admin routes and views, the auth collections, the sidebar |
auditModule | @questpie/admin/modules/audit | An admin_audit_log collection and a cleanup job |
openApiModule | @questpie/openapi | /api/openapi.json and the Scalar page at /api/docs |
workflowsModule | @questpie/workflows/modules/workflows | Workflow tables, jobs, routes, and a workflows service |
mcpModule | @questpie/mcp | The /api/mcp endpoint an agent connects to |
starterModule | questpie | The auth, OAuth and assets tables, with no admin panel |
adminModule already depends on starterModule, so listing both is redundant.
Reach for starterModule when you want auth tables and no admin UI.
An empty array is still a working app
createApp prepends questpie-core unless you listed it yourself. That
module carries the CRUD routes, the built-in field types, and the services
behind db, storage, queue and auth. What it does not carry is a user
table. That one lives in the starter.
Write one
A module is a plain object, and there are two ways to arrive at one.
Write the object yourself, which the rest of this section shows. Or lay the module out as directories and let codegen build the object, which Publishing covers. Both produce the same shape, so nothing downstream can tell them apart.
Pick by size. The object form suits a module you can read in one screen. Every
module QUESTPIE ships uses the directory form, because core alone carries
three jobs and more than a dozen routes.
module() is an identity function. It returns your object unchanged, so it
costs nothing at runtime. It is there to check the shape and carry your
object's type forward.
import { module } from "questpie/app";
import { collection } from "#questpie/factories";
export const blogModule = module({
name: "blog", // unique, this is the de-dup key
collections: {
posts: collection("posts")
.fields(({ f }) => ({ title: f.text(255).required() }))
.title(({ f }) => f.title),
},
messages: { en: { "blog.published": "Published" } },
});Add blogModule to the modules.ts array and run questpie generate.
app.collections.posts now exists, with the types a file under collections/
would have given it.
Import collection from #questpie/factories, not from questpie. The
generated factory is the one that knows your enabled modules, so module field
types like f.richText() appear on f. module comes from questpie/app.
That barrel does not re-export collection.
What a module can carry
name is the only required key. The last column is what happens when two
modules use the same one.
| Key | Contributes | Two modules, same key |
|---|---|---|
name | The identifier, and the de-dup key | Last one wins |
modules | Dependencies, resolved before this one | Flattened in |
collections | Collections, keyed by name | Override by key |
globals | Globals, keyed by name | Override by key |
routes | Routes, keyed by path | Override by key |
jobs | Job definitions, keyed by name | Override by key |
services | Services, keyed by name | Override by key |
channels | Realtime channels | Override by key |
fields | Field factories, from its fields.ts | Override by key |
migrations | Migrations | Both run |
seeds | Seeds | Both run |
messages | Backend messages, keyed by locale | Merged per locale |
config | One key per config/*.ts file | Merged per key |
plugin | A codegen plugin, or an array of them | Codegen only |
The interface ends in an index signature, so a package can add keys of its own.
@questpie/admin uses that for views, components and blocks. Those merge
like any other record. You never write those keys by hand. Codegen fills them
from the package's own files.
A module holds finished definitions
Put collections, jobs and services straight into the object. Nothing inside a module runs. Work that has to happen at boot belongs in a service lifecycle.
Your files win
Modules resolve first. Your own collections/, routes/ and the rest are
folded in last. So a key you define replaces the module's version outright.
That is the override seam, and it is also the trap. Redefining user from
scratch drops the auth columns and the admin config the starter set up. Extend
it instead. .merge() folds another builder of the same name into yours.
import { starterModule } from "questpie";
import { collection } from "#questpie/factories";
export const user = collection("user")
.merge(starterModule.collections.user)
.fields(({ f }) => ({ bio: f.textarea() }));@questpie/admin uses the same call to lay its UI config over the starter's
collections. See Collections.
The plugin key is different
plugin is the one key that never reaches the running app. Codegen reads it in
a pre-pass, then drops it before the merge.
That separation is why one array is enough. A package with file conventions of
its own puts its CodegenPlugin on the module. Adding the module registers the
entities and the conventions together.
Where each topic lives
| Topic | Page |
|---|---|
| Resolution order, and the rule for every key | Merging |
| Shipping a module as an npm package | Publishing |
modules.ts beside the other config files | Configuration |
Writing the CodegenPlugin a module ships | Building a plugin |
| Extending a collection a module ships | Collections |
Next
Codegen is the pipeline that reads modules.ts,
folds every module into one app, and writes the types you import.