Scaffold and run your first EFC API in under a minute. You need Node.js 22 or later (24 recommended); MongoDB, PostgreSQL or Redis only if you choose features that use them.
Every feature is a registry item — tick it now or add it later with efc add <item>. Some ask a follow-up: the Email client asks for Gmail or a custom SMTP host (see Mailer); Sign in with Google / Apple ask for their credentials.
It then writes the project, generates .env with a real JWT_SECRET, and runs npm install.
Start the dev server
$ cd my-api
$ npm run dev
npm run dev runs efc start dev: one process with hot reload. Tables and indexes are created the first time a model is used, so there's no migration to run first. The scaffolded src/api/health.ts answers at http://localhost:3000/v1/api/health — /v1/api is the default basePath.
In development, http://localhost:3000/ shows the API dashboard: every route, with its methods and documentation.
Add a route
$ efc generate route posts/[slug]
# → Created src/api/posts/[slug].ts
That's it — the file is registered as /posts/:slug the next time the server reloads. Check the result any time with efc routes.
Every EFC project follows the same layout. src/api/ and src/tasks/ are fixed conventions — there is no option to move them.
my-api/
├── src/
│ ├── api/ # HTTP routes — every file is a route
│ │ ├── health.ts # GET /health
│ │ ├── auth/
│ │ │ ├── login.ts # POST /auth/login
│ │ │ └── logout.ts # POST /auth/logout
│ │ └── users/
│ │ ├── index.ts # GET /users · POST /users
│ │ └── [id].ts # GET /users/:id · DELETE /users/:id
│ ├── tasks/ # background jobs (scanned non-recursively)
│ │ └── SendEmail.ts # task name: "SendEmail"
│ ├── model/ # models
│ │ ├── User.ts
│ │ └── Role.ts # with the RBAC option
│ ├── migrations/ # efc db migrate — run in filename order
│ ├── seeds/ # efc db seed
│ └── index.ts # entry point — calls ignite()
├── efc.config.ts # builds the config ignite() receives
├── .env # secrets — gitignored
├── .env.example # documented template — committed
├── package.json
└── tsconfig.json
Conventions
Every .ts / .js file in src/api/ becomes a route; its path is the URL.
[brackets] in a file or folder name become URL parameters.
index.ts maps to its folder's path.
Every file directly inside src/tasks/ registers a task named after the file. Subfolders are not scanned.
src/index.ts is what runs. efc.config.ts only takes effect because src/index.ts imports it.
Production builds
efc build prod transpiles every file under src/ into dist/src/ without bundling — routes and tasks are discovered by scanning the filesystem, so they must exist as files. efc start prod runs dist/src/index.js.
Enabling User portal or Admin portal fills src/model/ and src/api/ out considerably — see Generated Portals.
Configuration
ignite() takes one config object. It never reads efc.config.ts or .env by itself — you import the config and pass it in, so what you see is what runs.
efc.config.ts
The scaffolder generates a config that reads each environment variable explicitly:
EFC scans src/api/ at startup and builds the route map from the file tree. No registration — add a file, get a route.
Routing rules
Take the path relative to src/api, drop the extension and a trailing /index, then convert each segment:
File
URL
req.params
health.ts
/health
—
users/index.ts
/users
—
users/[id].ts
/users/:id
id: string
posts/[slug]/comments.ts
/posts/:slug/comments
slug: string
users/[[id]].ts
/users and /users/:id
id?: string
docs/[...slug].ts
/docs/a/b (one or more)
slug: ['a', 'b']
docs/[[...slug]].ts
/docs, /docs/a/b (zero or more)
slug: string[]
Every route is mounted under basePath — /v1/api unless you change it. A catch-all must be the last segment; anything else is a startup error.
Specificity
Routes register most specific first, comparing one segment at a time: static, then [id], then [[id]], then [...slug], then [[...slug]]. users/me.ts therefore always matches before users/[id].ts, regardless of file order.
Method dispatching
Export uppercase HTTP method names — GET POST PUT PATCH DELETE HEAD OPTIONS. A method the file doesn't export answers 405 Method Not Allowed with an Allow header.
Export a meta object to document the route in the API dashboard. It is keyed by HTTP method, so every method gets its own description and examples. All fields are optional.
export const meta = {
GET: {
description: 'Fetch a user by ID.',
request: { params: { id: 'usr_01HXZ' } },
response: {
status: 200,
body: { id: 'usr_01HXZ', name: 'Ada Lovelace', createdAt: '2026-01-01T00:00:00.000Z' },
},
},
DELETE: {
description: 'Delete a user by ID.',
response: { status: 204 },
},
}
Route Handlers
Handlers are plain Express async (req, res) => {} functions. Thrown errors and rejected promises are caught for you.
Static route
// GET /health
export const GET = async (req, res) => {
res.json({ status: 'OK', ts: new Date().toISOString() })
}
Dynamic route
// GET /users/:id
import { HttpError } from 'express-file-cluster'
import { User } from '../../model/User.js'
export const GET = async (req, res) => {
const user = await User.findById(req.params.id)
if (!user) throw new HttpError(404, 'User not found')
res.json(user)
}
Validating input
export const schema validates params, query and body with any Standard Schema validator — zod, valibot, arktype. It runs after the file's middlewares. Failures answer 400 with issues; on success the parsed output replaces the input, so coercions and defaults apply.
import { z } from 'zod'
// one schema for every method in the file…
export const schema = {
params: z.object({ id: z.string().uuid() }),
}
// …or keyed by method
export const schema = {
GET: { query: z.object({ include: z.enum(['reviews', 'stock']).optional() }) },
PATCH: { body: z.object({ price: z.number().positive() }) },
}
Since 0.11.0, generate an OpenAPI 3.1 document from the routes, request validators and metadata your app already uses.
import { ignite } from 'express-file-cluster'
await ignite({ openapi: true }) // GET /openapi.json
OpenAPI is opt-in. Validators need Standard JSON Schema support or an explicit schemaToJSONSchema adapter. Use meta.GET.openapi for response schemas, security declarations and other operation overrides. Hidden/protected GET and HEAD routes cannot be shadowed by the document endpoint.
Object query fields use bracket syntax such as ?filter[name]=Alice; array keys use tags[] so one item remains an array. Optional bodies stay optional, and equivalent optional URL variants preserve Express parameter bindings. For offline use, call generateOpenAPI('/absolute/path/to/api').
Export WS from a route file to accept WebSockets on the same HTTP server and API base path.
import type { WebSocketHandler } from 'express-file-cluster'
import { requireAuth } from 'express-file-cluster/auth'
export const middlewares = [requireAuth]
export const WS: WebSocketHandler = (socket) => {
socket.on('message', data => socket.send(data))
}
Connect to ws://localhost:3000/v1/api/chat, or use wss:// behind TLS. Global/file middleware, cookies, authentication, named params and schema.WS handshake validation apply before upgrade. HTTP handlers can coexist in the same file.
Defaults: same-origin browser policy, 1 MiB inbound messages, 30-second heartbeat and a 10-second handshake deadline. Configure ignite({ websocket: { origins: ['https://app.example.com'] } }), or disable upgrades with websocket: false. Shutdown drains connections and destroys pending handshakes.
Since 0.12.0, trace HTTP requests and background tasks across BullMQ, pg-boss, retries and worker-thread handoffs.
After initializing your application's OpenTelemetry SDK and context manager, enable ignite({ tracing: true }). Select components with tracing: { http: true, tasks: true }. Tracing is off by default; your application owns sampling, exporters and SDK configuration.
W3C trace context travels separately from task payloads. Request logs include trace and span IDs. HTTP spans use route templates; task handlers start with a fresh request/auth scope. Configure tracing.shutdown to flush your SDK after HTTP and task drain.
For one request: global middlewares → the file's middlewares → schema validation → the handler (including anything it composes).
Error Handling
Throw from any handler or middleware — EFC catches it and sends a clean JSON response.
HttpError
import { HttpError } from 'express-file-cluster'
export const GET = async (req, res) => {
const user = await User.findById(req.params.id)
if (!user) throw new HttpError(404, 'User not found')
res.json(user)
}
{ "error": "User not found", "statusCode": 404 }
Any other thrown error becomes a 500 with "Internal Server Error" — the message is not leaked to the client.
Custom global handler
import { HttpError } from 'express-file-cluster'
import type { ErrorRequestHandler } from 'express'
const onError: ErrorRequestHandler = (err, req, res, _next) => {
const status = err instanceof HttpError ? err.statusCode : 500
res.status(status).json({
error: status === 500 ? 'Internal Server Error' : err.message,
requestId: req.headers['x-request-id'],
})
}
const config: EFCConfig = { onError }
Response reference
Scenario
Status
throw new HttpError(n, msg)
n
Any other thrown error
500
Method not exported by the route file
405 + Allow
Route schema validation failed
400 + issues
Model validation failed (ModelValidationError)
400 + issues
Unique constraint violated
409
requestTimeout exceeded
408
Missing or invalid token
401
Role not allowed by requireAuth('role')
403
Clustering
With cluster: true — the default when NODE_ENV=production — the primary forks one worker per CPU. All workers share the port and the operating system spreads connections across them.
Pre-Flight, per worker
Every worker boots independently, in this order:
Configure logging (before the primary/worker split)
Create Express: request context and IDs, requestTimeout, CORS, JSON, cookies
Connect the database and set the model engine, then the cache
Configure auth (when jwtSecret is set)
Scan and register tasks from src/tasks
Start the queue backend — cron schedules sync in the background
Scan routes from src/api
Mount routes
Listen
Self-healing
When a worker exits unexpectedly, the primary calls onWorkerCrash and forks a replacement, which runs the full Pre-Flight before taking traffic.
gracefulShutdown(server) handles SIGTERM and SIGINT. A worker stops accepting connections, lets in-flight requests and task jobs finish, then exits (forced after 10 s). The primary stops respawning and relays the signal to every worker — so docker stop and Kubernetes, which signal only PID 1, don't hang.
ignite(config).then((server) => gracefulShutdown(server, 30_000)) // 30 s drain window
In development, efc start dev runs a single process with hot reload.
Background Tasks
Define tasks in src/tasks/, enqueue them from any route and respond immediately — the job runs off the request path.
import { enqueue } from 'express-file-cluster/tasks'
export const POST = async (req, res) => {
const user = await User.create(req.body)
await enqueue('SendEmail', { to: user.email, subject: 'Welcome' })
res.status(202).json({ id: user.id, queued: true })
}
enqueue rejects if no backend is configured, the task name doesn't exist, or the backend doesn't accept the job within tasks.enqueueTimeout (default 5000 ms) — it never hangs, so it's safe to answer 503.
Task options
Option
Default
Description
thread
false
Run in worker_threads.
retries
3
Re-runs after a failure — up to retries + 1 attempts before dead-lettering.
backoff
'exponential'
'fixed' or 'exponential' (1 s, doubling).
concurrency
tasks.concurrency
Parallel jobs of this task.
schedule
—
Cron pattern; scheduled runs receive {}. Schedules reconcile on boot.
With authStrategy: 'bearer' the same route sends the token in the body instead of a cookie. The client keeps it in secure storage (Keychain / Keystore) and sends Authorization: Bearer <token>. Browsers — SPAs included — should stay on 'http-only': any script on the page can read a token kept in localStorage. ('localStorage' is the old name for 'bearer' and still works.)
Protecting routes
import { requireAuth, getCurrentUser } from 'express-file-cluster/auth'
export const middlewares = [requireAuth] // 401 without a valid token
export const GET = async (req, res) => {
res.json({ me: req.user, alsoMe: getCurrentUser() })
}
getCurrentUser() returns the token payload anywhere in the request — models and helpers included.
Logging out
Call
Effect
revokeToken(res)
Clears the cookie only. The token itself stays valid until it expires.
revokeSession(req, res?)
Rejects the current token from now on (and clears the cookie). Needs cache.
revokeAllSessions(userId)
Rejects every token issued to the user so far — "log out everywhere". Needs cache.
Role-Based Access Control
Role checks are built into requireAuth — call it with role names instead of reaching for a separate middleware.
requireAuth(...roles)
import { requireAuth } from 'express-file-cluster/auth'
// bare — authentication only
export const middlewares = [requireAuth]
// authentication + role — 403 unless payload.role is one of these
export const middlewares = [requireAuth('admin')]
export const middlewares = [requireAuth('user', 'admin')]
Generated by the scaffolder
Toggle Role-based access control in create-efc-app and every protected route it generates uses this shorthand instead of an inline user.role === 'admin' check. It also generates a Role model and — with the Admin portal — a full /admin/roles CRUD API.
Database
Two model APIs. defineModel runs unchanged on MongoDB (mongoose) and PostgreSQL (Drizzle). defineTable is PostgreSQL-native, with Prisma's query vocabulary and real relations.
The engine follows database, or the databaseUrl scheme. Install mongoose for MongoDB, or pg and drizzle-orm for PostgreSQL.
defineModel
import { defineModel, type InferRecord } from 'express-file-cluster'
export const User = defineModel('User', {
name: { type: 'string', required: true },
email: { type: 'string', required: true, unique: true },
role: { type: 'string', enum: ['member', 'admin'], default: 'member' },
})
export type UserRecord = InferRecord<typeof User> // no interface to write
Filters $eq $ne $gt $gte $lt $lte $in $nin $exists $regex $and $or, dotted paths and array-contains behave the same on both engines; updates support $set $unset $inc $push $pull. Invalid fields throw ModelValidationError (400); a duplicate unique value is a 409.
Also findUnique, upsert (INSERT … ON CONFLICT), groupBy, aggregate, relation filters and nested writes. Full reference: defineTable ↗.
Schema sync
A model's table or collection — columns, indexes, foreign keys — is reconciled on first use (autoSync, default on). Sync adds and converts; it never drops a column or a row. Preview changes with efc db sync --dry-run, or set autoSync: false and use migrations.
import { db } from 'express-file-cluster'
// MongoDB: a mongoose Connection · PostgreSQL: a pg Pool
const { rows } = await db.query('SELECT * FROM users WHERE id = $1', [id])
db is only available after Pre-Flight — don't touch it at module load time.
Mailer
Toggle Mailer in the scaffolder for a working nodemailer transport, wired to environment variables, and a real SendEmail task.
Choose a provider
? Email provider:
❯ Gmail smtp.gmail.com, preconfigured
Other (custom SMTP) you'll provide host + port
With Gmail no host or port is asked — SMTP_HOST / SMTP_PORT are set to smtp.gmail.com / 465. Other prompts for both.
Gmail needs an App Password
Google rejects your normal password over SMTP. The wizard accepts only a 16-character App Password:
Send mail with enqueue('SendEmail', payload) — see Background Tasks. With the User portal enabled too, registration, password reset and email verification enqueue it for you.
API Dashboard
In development (NODE_ENV=development), GET / serves a live page listing every route — one card per HTTP method, with its description and request/response examples. No external tooling.
Turning it on and off
It is on by default in development and does not exist in production. Set dashboard: false to disable it in development too.
Documenting a route
The cards come from each file's meta export, keyed by method. A method without an entry still gets a card, marked undocumented.
Response bodies are shown as type names, not your sample values — examples stay readable without leaking data.
Value in meta
Shown as
'Ada Lovelace'
String
42891
Number
true
Boolean
'2026-01-01T00:00:00.000Z'
Date (ISO strings are detected)
Generated Portals
The User portal and Admin portal options scaffold a large, working set of models and routes — not stubs. You pick the sub-features you want, and only what they need is generated.
Every model is real on both databases: defineModel collections on MongoDB; on PostgreSQL, defineTable models (or defineModel, if you chose it) linked by foreign keys, with relations such as user.sessions and plan.subscriptions ready for include.
Always generated
POST /auth/login, POST /auth/logout and src/model/User.ts.
Dashboard stats; user management (suspend, activate, verify, CSV export); analytics; admin management; notification broadcast; audit, activity and security logs; system health; ticket triage; content (FAQs, blog, categories); billing (plans, coupons, subscriptions) — plus /admin/roles when RBAC is on.
Protected routes export middlewares = [requireAuth], or [requireAuth('admin')] with RBAC. A src/migrations/00000000000000_init migration creates every generated table or collection.
CLI Reference
The efc binary comes with express-file-cluster. A global efc run inside a project hands off to the project's own version.
Lifecycle
Command
Description
efc start dev
Development server — single process, hot reload.
efc build prod
Type-check and transpile src/ to dist/src/.
efc start prod
Run dist/src/index.js, clustered.
efc run tests
Vitest.
Code generation
Command
Creates
efc generate route users/[id]
A route file with method stubs
efc generate task SendEmail
A background task
efc generate middleware authorize
A middleware file
efc generate migration add-orders
A timestamped migration
efc generate seed users
A seed script
Database
Command
Description
efc db sync [--dry-run]
Reconcile tables / collections and indexes with your models.
efc db migrate
Run pending migrations as one batch.
efc db rollback [--steps 2]
Revert the last batch(es).
efc db status
Applied vs pending migrations.
efc db seed [name]
Run all seeds, or one.
efc db studio [--write]
Database browser on 127.0.0.1 — read-only, or with --write insert/edit/delete through your models (validated).
Diagnostics and tasks
Command
Description
efc routes
The resolved route table: path, file, methods.
efc tasks
Registered background tasks.
efc tasks failed | retry | purge
Inspect and act on dead-lettered jobs.
efc doctor
Check config, environment variables and project setup.
efc --version
Installed version.
Which Model API?
Three ways to define a model, one foundation: the schema format, inferred types, validation, auto-sync, transaction() and errors are shared. They differ in engine and query vocabulary.
defineCollection keeps mongoose's query language with the schema as a plain object and types inferred from it. It is mongoose underneath — your data and indexes stay as they are.
export const Place = defineCollection('Place', {
name: { type: 'string', required: true },
price: { type: 'decimal128' },
location: { type: 'geoPoint', index: '2dsphere' },
owner: { type: 'objectId', ref: 'User' },
})
export type Place = InferRecord<typeof Place> // no interface to keep in step
{ populate: 'owner' } · or Place.lookup('owner', User)
Model.aggregate([...])
Place.pipeline().match(…).group(…) — typed per stage
an existing database
efc db pull writes the models from sampled documents
Environment Variables
ignite() doesn't read process.env for configuration — the scaffolded efc.config.ts does, and passes the values in. NODE_ENV is the one variable the framework reads directly.
All three packages ship together under one version, following semver.
Bump
Before 1.0
From 1.0
Patch
Fixes, security fixes, clearer errors — no new API
Fixes and security fixes only
Minor
Features; breaking changes listed with upgrade steps
Backwards-compatible features
Major
—
Breaking changes, with upgrade steps
LTS from 1.0: the current major gets everything; the previous major gets security and critical fixes for 12 months after the next one ships. Before 1.0, only the latest 0.x release gets fixes.
Node.js: active and maintenance LTS lines — tested on Node 22 and 24.
0.10.2 auth fixes: update owned login, refresh, logout, reset-password, change-password and two-factor helper files. Reset links are consumed atomically and invalidate refresh credentials. Custom-entity logout propagates revocation failures. ESM/CommonJS middleware shares auth, request context and cache state.
Generated code is yours: upgrading the package doesn't change files create-efc-app or efc add wrote. When a release fixes generated code, the changelog names the files — re-add them with efc add <item> --overwrite or apply the change by hand.