Scaffold and run your first EFC API in under a minute. You need Node.js 18 or later; MongoDB, PostgreSQL or Redis only if you choose features that use them.
Background tasks → a queue backend: BullMQ (Redis) or pg-boss (stores jobs in your PostgreSQL — no Redis to run).
Mailer → Gmail (preconfigured, App Password prompt) or a custom SMTP host. See Mailer.
User / Admin portal → which sub-features to generate. See Generated Portals.
RBAC → a Role model and requireAuth('role') on protected routes. See RBAC.
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() }) },
}
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.
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
Read-only database browser on 127.0.0.1.
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.
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.