Project structure and configuration
Layout
A Cell project is an npm package. Its source follows a few conventions; nothing has to be registered elsewhere.
| Path | What |
|---|---|
src/node/ | Backend code: controllers, services, middleware |
src/browser/ | Frontend code (when the project has a frontend) |
src/common/ | Code both sides import, such as RPC interfaces |
src/node/module.ts | The backend DI module |
src/browser/module.ts | The frontend DI module |
cell.yml, cell.<mode>.yml | Application configuration |
cell.config.ts (optional) | Toolchain configuration |
.cell/ | Generated: generated/ (DI wiring, merged configuration) and output/ (the build); add it to .gitignore |
DI modules
A DI module is a file that imports the files declaring components and exports autoBind():
// src/node/module.ts
import './todo-controller';
import './todo-service';
import { autoBind } from '@celljs/core';
export default autoBind();autoBind() registers every class decorated so far (@Component, @Controller, @Rpc, ...). A file that no module imports is never loaded: if cell check reports CELL_DI_NO_BINDING for a class you wrote, import its file here.
The toolchain looks for these module files of the project, in this order, and loads the ones that exist:
| Target | Files (under src/, extension .ts, .tsx, .js or .mjs) |
|---|---|
| backend | common/module, node/backend-module, node/module, module, backend-module |
| frontend | common/module, browser/frontend-module, browser/module, module, frontend-module |
src/common/module and src/module are loaded on both sides. A package can declare other paths in the cell.modules field of its package.json.
Components
Every dependency whose package.json has a cell field is a component: installing it adds its DI modules and its cell.yml to the application. Installing @celljs/security turns on login, installing @celljs/serve-static serves the frontend. To leave out a component that a dependency brings in, list it in cell.config.ts:
export default defineConfig({ modules: ['!@celljs/serve-static'] });npx cell info lists the components, their DI modules, the entry and the preset Cell resolved; --json gives the same as data.
Application configuration: cell.yml
# yaml-language-server: $schema=./.cell/schema/cell.yml.json
server:
port: ${env.PORT ?? 8080} # keys of the components are at the top level
typeorm:
ormConfig:
- type: mysql
password: ${env.DB_PASSWORD}
app: # your own configuration goes under app
greeting: Hello
public: # only app.public reaches the browser
apiBase: /api- Top-level keys are configuration keys; there is no
cell:,frontend:orbackend:level. Read them with@Value('server.port'),@Value('app.greeting')on a property or constructor parameter. - Every top-level key has an owner: a component declares the keys it owns in its
cell.schema.json(serverbelongs to@celljs/web,securityto@celljs/security...). Writing under them overrides the component's defaults. - Your own configuration goes under
app, which no component takes, so an upgrade never collides with it. - Mistakes are errors: a top-level key nobody owns, an option a component does not have, a wrong type —
cell build,cell devandcell checkreportCELL_CONFIG_INVALIDwith the file, the line and a "did you mean". cell dev,cell buildandcell checkwrite.cell/schema/cell.yml.json; with theyaml-language-servercomment above as the first line ofcell.yml, editors (the YAML extension of VS Code, ...) complete and check it.
What the frontend sees
Configuration is server-only by default. The browser only receives the keys components mark public (such as server.path, rpc, react, the page redirects of security) and app.public. A secret never reaches the frontend bundle unless you put it under app.public.
- Frontend values are evaluated at build time; their expressions may refer to server keys, only the results ship.
npx cell config get <key> --target frontendshows what the frontend gets; a server-only key is reported as such.- A project may have its own
cell.schema.jsondescribing the keys underapp(types, descriptions,"x-cell-public": true).
Merge order
The files merge in this order: the cell.yml of every component (in dependency order), the project's cell.yml, then cell.<mode>.yml for each active mode. Later files win; objects merge deeply, arrays are replaced.
Modes
| Command | Mode |
|---|---|
cell dev | development |
cell build | production |
createTestApp() | test |
Add more with -m <mode> (repeatable): cell build -m staging also reads cell.staging.yml. A mode without any file is reported by cell check (CELL_MODE_UNKNOWN).
Expressions
${...} is evaluated against the configuration itself plus env (the process environment):
server:
endpoint: http://localhost:${server.port} # another key
cloud:
region: ${env.REGION ?? 'eu-west-1'} # environment variable with a default
typeorm:
ormConfig:
- port: ${env.DB_PORT ?? 5432} # the schema says integer: DB_PORT=6543 gives the number 6543
synchronize: ${env.DB_SYNC ?? false} # the schema says boolean: true or false only- Defaults use
??, as in JavaScript; an empty environment variable counts as unset. Values need no quotes. - Schema types convert: when a key is a number or a boolean, the text an expression gives is converted, and a value that does not convert is an error naming the key. Keys without a schema (those under
app) keep the type of the result. - Backend values are evaluated when the server starts, so one build runs in every environment and secrets are not baked into it. Frontend values are evaluated at build time.
- An expression inside other text gives a string; the result of an expression is never evaluated again, even when an environment variable contains
${...}.
Environment variables
Override any existing key without editing files, with a variable starting with CELL_:
CELL_SERVER__PORT=8080 node .cell/output/server/index.mjs # server.port, applied at startup
CELL_SERVER__HOST=0.0.0.0 # listen on all interfaces (containers)
CELL_APP__PUBLIC__API_BASE=https://api.example.com npx cell build # app.public.apiBase of the frontend, at build time- One underscore after the prefix,
__between levels (CELL_SERVER__PORT).CELL_PRESET,CELL_MODE,CELL_EVENT,CELL_OUT_DIRandCELL_DEBUGbelong to the toolchain and are never configuration; a one-segment variable that is no top-level key (another tool'sCELL_REGION) is left alone; a name with__that matches no key is reported, with the right spelling for the earlierCELL__…/CELL_BACKEND__…. __separates levels, matched ignoring case,_and-(ORM_CONFIGmatchesormConfig); array items by index.- The same variables apply to both sides: the server applies them at startup;
cell buildapplies them, then picks the public keys for the frontend. - Only existing keys can be overridden; give a new key a default in
cell.ymlfirst. - Values convert to the type of the value (or the schema): numbers, booleans, JSON for objects and arrays. A value that is one
${...}is kept and evaluated:CELL_SERVER__PORT='${env.PORT}'. - A variable that matches no key or does not fit its type is ignored with a warning;
cell checkandcell inforeport it.
Where a value comes from
npx cell config get server.port --why
# server.port (backend) = 8080
# server-only, owned by @celljs/web
# Port the server listens on. Override with CELL_SERVER__PORT.
# node_modules/@celljs/web/cell.yml: 3000
# cell.yml: 8080Common keys of the framework:
| Key | Default | Package |
|---|---|---|
server.port / server.host | 3000 / 127.0.0.1 | @celljs/web |
server.path | / | @celljs/web (prefix of every route; public) |
web.body.limit | 100kb | @celljs/web |
web.cors | any origin (*) | @celljs/web |
web.session / web.cookies | see the README of @celljs/web | @celljs/web |
html.title / html.rootId | Cell Framework / cell-root | @celljs/core (the generated HTML page) |
logger.level | info | @celljs/core |
rpc.path | api | @celljs/rpc |
serveStatic.spa | true | @celljs/serve-static |
Each package's README lists its keys; every key with its type and description is in .cell/schema/cell.yml.json.
Toolchain configuration: cell.config.ts
Optional, and only for the toolchain. Application configuration never goes here.
import { defineConfig } from '@celljs/cli';
export default defineConfig({
preset: 'cloudflare', // default: node
modules: ['!@celljs/serve-static'], // remove a component
build: { external: ['sharp'], sourcemap: true },
vite: { resolve: { alias: { '~': '/src' } } }, // merged into the Vite configuration
hooks: { 'build:before': 'prisma generate' }, // shell commands on toolchain events
fc: { region: 'cn-shanghai', memory: 512 }, // options of a preset, under its name
});Unknown fields are reported with the closest valid name (CELL_CONFIG_INVALID, CELL_CONFIG_UNKNOWN_OPTION). The full reference, including toolchain modules that add Vite plugins, checks, MCP tools or output files, is the CLI reference.
Generated files
cell dev and cell build write the code they generate to .cell/generated/: the DI modules each target loads, the merged configuration and the project model. Read them when you need to know exactly what the container contains; never edit them.