Node.js

Tulipes (tulipesjs): the TypeScript framework for Express 5 that refuses to boot a broken API

tulipesjs is a TypeScript framework for Express 5 on Node.js that validates env vars, modules, permissions and routes at boot, before the first request.

Kemo
Updated 15 min read
The tulipesjs logo on cream above the headline "The Express framework that refuses to boot a broken API", beside a dark green terminal card where boot phase 1 of 12 is marked red and three invalid environment variables are listed
On this page
  1. What is Tulipes (tulipesjs)?
  2. Validate environment variables at startup: three broken, one crash
  3. The Tulipes boot pipeline, phase by phase
  4. Quick start: create an Express 5 API with tulipes init
  5. One folder per feature: a modular Express app structure
  6. Typed environment variables, declared per module
  7. Route-level permissions: every route declares its RAI, or the app does not start
  8. One codebase for the API server, the queue worker and scripts
  9. Generate OpenAPI 3.1 and Postman from Express routes, without a database
  10. Expose AI agent tools over MCP (Model Context Protocol)
  11. Graceful shutdown in Node.js, in the right order
  12. Tulipes vs NestJS vs plain Express
  13. What Tulipes does not promise yet
  14. Questions people ask about Tulipes
  15. Where to start
  16. Sources and further reading

Most Node.js APIs find out they are misconfigured from their first request: a STRIPE_API_KEY that was never set, a permission check someone forgot, a route nobody guarded. Tulipes (tulipesjs, published on npm as @tulipes/core) is a TypeScript framework for Express 5 built on the opposite rule. It checks everything that can be checked before the server listens, and when something is wrong it refuses to start, with one report that lists every problem at once.

This article introduces Tulipes from a real session: the published CLI, a scaffolded app, and the same app broken on purpose to capture what the framework actually prints. You will see the twelve-phase boot pipeline, the module layout, typed environment variables, route-level permissions, OpenAPI generation without a database, and the AI agent tools Tulipes serves over MCP. The runnable sandboxes below are small ports of the framework's own source, so you can break things in your browser too.

Packages@tulipes/core 0.13.1 · @tulipes/cli 0.4.1 · @tulipes/spec · @tulipes/mcp · @tulipes/testing
Built onExpress 5, Zod, i18next
RuntimeNode.js 24 (the CLI's engines field is >=24.0.0 <25)
Optional providers@tulipes/mongoose (models), @tulipes/bullmq (queues), @tulipes/socket.io (sockets)
LicenceMIT
StatusStable API on the 0.13 line; agent surface experimental; no published production load evidence yet
Tulipes at a glance, as published on npm on 17 October 2026

What is Tulipes (tulipesjs)?

Tulipes is a module-based framework for building HTTP APIs, background workers and scripts on Express 5 in TypeScript. You write features as folders called modules — each holding its own routes, permissions, configuration, translations and jobs — and Tulipes discovers them, sorts them into a load order, validates their contracts and wires them into a running process. Core mounts no middleware and installs no database driver: MongoDB, BullMQ and Socket.IO arrive only when an app declares them as providers.

It is not a toy. The backend of this blog, k13, runs on Tulipes, and the tools this draft was filed through — the k13 MCP server's agent_mcp_* actions — are, in its own description, "the actions its modules declare".

Validate environment variables at startup: three broken, one crash

Here is the first thing to know about Tulipes, captured from a real run. A billing module declares three environment variables; the deployment sets two of them badly and forgets the third:

@tulipes/core 0.13.0 on Node 24.22.0. Stack trace trimmedShell
$ BILLING_TRIAL_DAYS=abc BILLING_WEBHOOK_URL=not-a-url node app.tsTulipesBootError: Boot aborted during "environment" — 3 issue(s):  ✖ [billing] required variable "STRIPE_API_KEY" has no value (Server-side Stripe key)  ✖ [billing] variable "BILLING_TRIAL_DAYS": "abc" is not a number  ✖ [billing] variable "BILLING_WEBHOOK_URL": "not-a-url" is not a valid URL

Nothing was served, and nothing was half-started. The boot stopped in its first phase and named every problem in it, so one deploy fixes all three instead of three deploys discovering them one at a time.

Ten missing environment variables should be one crash listing ten problems, not ten restarts.

The Twelve-Factor App puts configuration in environment variables, and validating them at start-up is already good practice: t3-env, envalid and hand-written Zod schemas do it, and NestJS's @nestjs/config checks them during bootstrap and reports every failing variable together. Tulipes applies the same idea to the whole application: the module graph, provider requirements, settings, translations, permissions and every route get the same treatment, each in its own phase.

Try the environment phase yourself. This sandbox is a trimmed port of the resolver in @tulipes/core (src/env/coerce.ts): process.env wins over the .envs/.env.<APP_ENV> file, which wins over the declared default, and every value is coerced to its declared type.

Resolve a deployment's environmentnode · env/main.mjs

Press Run, then follow the three “Try this” steps at the top of main.mjs. contract.mjs holds what the modules declare; resolve.mjs is the resolver ported from @tulipes/core.

ConsoleNot run yet
Output appears here.

The Tulipes boot pipeline, phase by phase

A Tulipes process boots through twelve phases in a fixed order. Each phase either hands a clean result to the next or stops the process with its own aggregate report, so a later phase never runs on top of an earlier mistake. The same path serves the web server, the queue worker and one-off scripts; only phase 11 differs.

Shared by every process1Environment2–3Module graph, providers4–6Config, models, settings7–8i18n, time zone, ACL, agent9–10Bootstrap, queues, sockets11PROCESS_MODEbackendroutes, RAI scan, listenworkerconsume queuesscriptreturn the handle12Banner, runningBoot refusedone reportinvalid valuescycle or missingproviderroute without aRAI
  • 1 → 2–3
  • 2–3 → 4–6
  • 4–6 → 7–8
  • 7–8 → 9–10
  • 9–10 → 11
  • 11 → backend
  • 11 → worker
  • 11 → script
  • backend → 12
  • worker → 12
  • 1 → Boot refused: invalid values
  • 2–3 → Boot refused: cycle or missing provider
  • backend → Boot refused: route without a RAI
Every process walks the same phases; PROCESS_MODE only decides the last step.
PhaseWhat happensRefuses when
1 · EnvironmentReads APP_ENV, finds every module, resolves each declared variableA required value is missing or a value does not match its type
2–3 · Module graphSorts modules by tier, priority and dependsOn; locates files by convention without importing themA cycle, an unknown dependency, or a sys module depending on an app module
2–3 · ProvidersReads tulipes.providers from the app's package.jsonA module has models/, queues/ or sockets/ files and no provider is declared
4–6 · Config, settingsSeeds config/app.config.ts, connects the models provider, loads and validates every settings namespaceA stored setting fails its module's Zod schema
7–8 · Language, time, accessLoads translations, applies the time zone, merges every module.acl.ts, loads agent actionsInvalid i18n JSON, an unknown zone, a grant to an undefined role, a duplicate grant
9–10 · Bootstrap, infrastructureRuns bootstrap/*.bootstrap.ts in load order, then acquires queues and socketsA bootstrap throws
11 · Forkbackend mounts routes and listens; worker starts consuming; script starts nothingA route without rai(); a worker with nothing to consume
12 · BannerPrints what booted—
What each boot phase checks, and what makes it refuse to start
The whole Tulipes boot pipeline on one page, drawn in Eraser.

Now play with it. Pick a process mode, switch on one or more faults and press Boot: like the real framework, the simulator stops at the first phase that fails and lists every fault inside that phase, while faults in later phases stay hidden. Every message it shows was printed by @tulipes/core 0.13.0 when the scaffolded app was broken in that exact way.

pipeline/index.html
<!doctype html><html lang="en"><head>  <meta charset="utf-8">  <meta name="viewport" content="width=device-width, initial-scale=1">  <title>Tulipes boot simulator</title>  <link rel="stylesheet" href="style.css"></head><body>  <main class="sim">    <header class="intro">      <p class="eyebrow">Boot simulator</p>      <h1>Break the app, then boot it</h1>      <ol class="how">        <li><b>1</b> Pick how the process runs</li>        <li><b>2</b> Switch on one or more faults</li>        <li><b>3</b> Press <kbd>Boot</kbd> and watch where it stops</li>      </ol>    </header>     <section class="controls" aria-label="Simulator settings">      <div class="group">        <h2>Process mode</h2>        <div class="seg" role="radiogroup" aria-label="Process mode">          <label><input type="radio" name="mode" value="backend" checked><span>backend<small>HTTP API</small></span></label>          <label><input type="radio" name="mode" value="worker"><span>worker<small>queue jobs</small></span></label>          <label><input type="radio" name="mode" value="script"><span>script<small>one-off task</small></span></label>        </div>      </div>       <div class="group">        <div class="row"><h2>Faults</h2><button id="clear" type="button" class="link">Clear</button></div>        <p class="hint">Each one is a real mistake. Turn on several: the boot reports every fault in the phase that fails.</p>        <div id="faults" class="faults"></div>      </div>       <button id="boot" type="button" class="boot">Boot <span id="cmd">node app.ts</span></button>    </section>     <section class="run" aria-label="Result">      <div class="card">        <div class="row"><h2>Boot phases</h2><span class="legend"><i class="ok"></i>passed <i class="bad"></i>refused <i class="skip"></i>never ran</span></div>        <ol id="phases" class="phases"></ol>      </div>      <div class="term">        <div class="bar"><span></span><span></span><span></span><em id="status">ready</em></div>        <pre id="out" aria-live="polite"><span class="dim">Press Boot to start. Every line here was printed by @tulipes/core 0.13.0.</span></pre>      </div>    </section>  </main>  <script src="app.js"></script></body></html>

Quick start: create an Express 5 API with tulipes init

Every command and output below is from a clean run of the published packages on 17 October 2026, Node 24.22.0, Linux. Your timings will differ.

  1. Install the CLI

    The CLI needs Node.js 24. Generated apps use pnpm, pinned through packageManager.

    npm install -g @tulipes/cli
  2. Scaffold an app

    Terminal
    $ tulipes init my-api  create app.ts  create config/app.config.ts  create modules/core/package.json  create modules/hello/routes/web.hello.routes.ts  …  create AGENTS.md  create CLAUDE.md  create .claude/skills/tulipes-endpoint/SKILL.md  …Project "my-api" is ready.No MongoDB or Redis required. Health: http://localhost:3000/health

    The default preset is minimal: a core and a security module (sys tier), a hello module and an agent module, with tests. It took under half a second here. --preset production copies the full reference application instead: JWT auth with refresh rotation, users, settings, MongoDB, BullMQ, Socket.IO, PM2 and nginx templates.

    What tulipes init my-api created
    • my-api/
      • app.tsboot({ mode: "backend" })
      • mcp.tsserves the agent tools over MCP
      • package.json
      • pnpm-workspace.yaml
      • tsconfig.json
      • vitest.config.ts
      • AGENTS.mdconventions for coding agents
      • CLAUDE.md
      • .claude/
        • skills/14 task-scoped skills: endpoint, env variable, model, queue…
      • config/
        • app.config.tsapp name, API prefix, port, time zone
        • mcp.config.ts
      • modules/
        • core/sys tier, priority 0: the roles and the PORT variable
          • package.json
          • module.acl.ts
        • security/sys tier, priority 10: helmet and a 100 kB body limit
          • package.json
          • routes/
            • security.routes.ts
        • hello/
          • package.json
          • module.acl.ts
          • i18n/
            • en.json
          • routes/
            • web.hello.routes.ts
            • web.hello.health.routes.ts
          • agent/
            • hello.agent.ts
        • agent/serves the agent tool manifest over HTTP
          • package.json
          • module.acl.ts
          • routes/
            • web.agent.routes.ts
      • tests/
        • app.test.tsboots the app once and checks spec parity
  3. Install, generate types, check the environment

    Terminal
    $ cd my-api && pnpm installDone in 8.2s using pnpm v10.28.0$ pnpm syncwrote types/tulipes.d.tswrote .env.example$ pnpm checkAPP_ENV=development — 1 variable(s) resolved:  ✔ config.core.port ← PORT=3000

    tulipes sync generates types from your modules; tulipes env:check resolves every variable without booting, which makes it a cheap CI gate.

  4. Run the tests

    Terminal
    $ pnpm test Test Files  1 passed (1)      Tests  4 passed (4)   Duration  756ms

    One of the four generated tests boots the app twice — once for real, once in inspection mode — and asserts that both produce the same OpenAPI document.

  5. Start it and call it

    Terminal
    $ pnpm dev  ▲ my-api  development · backend mode  ➜  Server      http://localhost:3000  ● listening  ➜  Models      no provider declared  ➜  Queues      no provider declared  ➜  Sockets     no provider declared  ➜  Settings    no module declares settings  ➜  Modules     4 (core*, security*, hello, agent)  ·  * sys tier  ➜  Locales     en  ·  1 translated module(s)  ➜  Time zone   UTC  ·  requests may ask for any zone  ·  process  ➜  Roles       1 (guest)  ➜  Runtime     Node v24.22.0  ·  PID 785  ·  ready in 116ms
    Terminal
    $ curl -s localhost:3000/api/v1/hello{"success":true,"data":{"message":"Hello"},"errors":[],"meta":{}}$ curl -s localhost:3000/nope{"success":false,"data":null,"errors":[{"field":null,"message":"no route for GET /nope","code":"ROUTE_NOT_FOUND"}],"meta":{}}
Build your install command

Choose a name, a starter and the first command: the script updates as you go, ready to paste into a terminal with Node.js 24. The minimal starter needs no database; production needs MongoDB and Redis, and --no-queues or --no-sockets leave that provider out.

npm install -g @tulipes/cli
tulipes init my-api --minimal
cd my-api
pnpm install
pnpm dev

One folder per feature: a modular Express app structure

A module is a workspace package under modules/. Every file is optional except package.json, and each file's role comes from its name, so there is no registration list to keep in step. Delete the folder and the feature is gone.

modules/billing/
  • modules/
    • billing/
      • package.jsonname, "tulipes" ordering key, env contract
      • module.config.tsdefineConfig({ schema }): this module's settings
      • module.acl.tspermission grants
      • routes/
        • web.billing.routes.ts(ctx) => Router, mounted under config.api.prefix
      • controllers/
        • web.billing.controllers.tsthe work: WebBillingController
      • helpers/
        • billing.helpers.tspure logic, no framework
      • i18n/
        • en.jsonthis module's strings; the module is the namespace
      • models/
        • invoice.model.ts{ name, schema }: a declaration, not a registration
      • bootstrap/
        • heartbeat.bootstrap.tsinitialisation in every mode; may return its cleanup
      • queues/
        • billing.queues.tsproducers and processors in one file
      • sockets/
        • billing.sockets.tsclaims a namespace
      • agent/
        • billing.agent.tswhat the module offers to AI agents

That is the full set from the @tulipes/core README. tulipes new module billing generates only the files your declared providers allow, and every one except package.json can be deleted.

Load order comes from three keys in package.json: tier (sys before app), then priority, then a topological sort on dependsOn. Sys modules load first, which is how the security module's helmet and body-size limit end up at the head of every request without core mounting anything itself.

modules/billing/package.jsonJSON
{  "name": "@my-api/billing",  "tulipes": { "tier": "app", "priority": 100, "dependsOn": ["core"] },  "dependencies": { "@tulipes/core": "^0.13.0", "@my-api/core": "workspace:*" }}

This sandbox is a port of sortModules() from @tulipes/core (src/modules/graph.ts). Give hello a dependency on billing and you get exactly the line the real boot printed: dependency cycle: billing → hello → billing.

Sort modules into a load ordernode · graph/main.mjs

Press Run to see the load order, then follow the “Try this” steps at the top of main.mjs to break the graph in modules.mjs. sort.mjs is the sort ported from @tulipes/core.

ConsoleNot run yet
Output appears here.

Typed environment variables, declared per module

A module declares what it needs from the deployment in plain JSON, so tooling can read the contract without running the module's code:

modules/billing/package.jsonJSON
{  "tulipes": {    "env": {      "stripeApiKey": { "key": "STRIPE_API_KEY", "type": "secret", "group": "billing", "required": true, "description": "Server-side Stripe key" },      "trialDays":    { "key": "BILLING_TRIAL_DAYS", "type": "number", "group": "billing", "default": 14, "description": "Free trial length" },      "webhookUrl":   { "key": "BILLING_WEBHOOK_URL", "type": "url", "group": "billing", "required": true, "description": "Where Stripe posts events" }    }  }}

pnpm sync turns that into types and a documented .env.example. A misspelt name no longer compiles:

types/tulipes.d.ts (generated, excerpt)TypeScript
declare module "@tulipes/core/config" {  interface EnvValues {    "billing": {      /** STRIPE_API_KEY — Server-side Stripe key */      readonly stripeApiKey: string;      /** BILLING_TRIAL_DAYS — Free trial length */      readonly trialDays: number;      /** BILLING_WEBHOOK_URL — Where Stripe posts events */      readonly webhookUrl: string;    };  }}
.env.example (generated, excerpt)Text
# ━━━ billing ━━━# Server-side Stripe key (secret, REQUIRED) → config.billing.stripeApiKeySTRIPE_API_KEY=# Free trial length (number) → config.billing.trialDaysBILLING_TRIAL_DAYS=14# Where Stripe posts events (url, REQUIRED) → config.billing.webhookUrlBILLING_WEBHOOK_URL=

Code reads the values as ctx.config.billing.trialDays, beside the module's settings. The detail that matters for security is that they are read-only and non-enumerable. A script booted against the app above printed this:

scripts/report.tsTypeScript
const app = await boot({ rootDir: import.meta.dirname, mode: "script" });console.log(app.ctx.config.billing.trialDays);           // 14console.log(Object.keys(app.ctx.config.billing));        // []console.log(JSON.stringify(app.ctx.config.billing));     // {}await app.shutdown();

So res.json(ctx.config.billing), a spread into a log line, or a settings export cannot leak STRIPE_API_KEY. tulipes env:check masks secrets too: ✔ config.billing.stripeApiKey ← STRIPE_API_KEY=•••. Tulipes also reads APP_ENV, deliberately not NODE_ENV, because bundlers and libraries overload NODE_ENV as an optimisation flag.

Write an environment variable declaration

Fill in the fields to generate the JSON a module puts in its package.json. After pnpm sync the value is typed at ctx.config.<module>.<name>, and the boot refuses to start if it is missing or the wrong type. Pick required or a default, never both.

{
  "tulipes": {
    "env": {
      "trialDays": {
        "key": "BILLING_TRIAL_DAYS",
        "type": "number",
        "group": "billing",
        "default": 14,
        "description": "Free trial length"
      }
    }
  }
}

Route-level permissions: every route declares its RAI, or the app does not start

In Tulipes a route is declared with rai() — a Resource Access Identifier. The id has the shape module:scope:resource[:extra], such as billing:web:list or users:bo:detail:delete, and it does three jobs at once: it names the endpoint, it is the ACL permission that gates it, and it binds req.t to the module's translations. Add Zod schemas and the same declaration validates the request and documents it.

modules/billing/routes/web.billing.routes.tsTypeScript
import { Router } from "express";import { z } from "zod/v4";import type { BackendCtx } from "@tulipes/core/boot"; export default function billingRoutes(ctx: BackendCtx): Router {  const router = Router();  router.get(    "/billing/invoices",    ctx.rai({      id: "billing:web:invoices",      name: "List invoices",      returns: z.object({ invoices: z.array(z.object({ id: z.string(), total: z.number() })) }),    }),    (req, res) => {      // req.auth.user is who is calling; rai() has already checked their role.      res.respond({ data: { invoices: [{ id: "inv_1", total: 4200 }] } });    },  );  return router;}

Forget the declaration and the boot fails after every route is mounted and scanned, rather than leaving an open endpoint in production:

boot reportText
TulipesBootError: Boot aborted during "routes" — 1 issue(s):  ✖ POST /api/v1/billing/refund has no rai() declaration

Grants live in each module's module.acl.ts. Roles are defined once; a grant is an id, *, or a prefix ending in :*. A grant to a role nobody defined, or the same grant twice, is a boot failure:

boot reportText
TulipesBootError: Boot aborted during "acl" — 2 issue(s):  ✖ [billing] allow() on unknown role "user" — roles must be defined first (defineRole)  ✖ [billing] "guest" already granted "billing:web:list" by module "billing" — duplicate grant

Access is denied by default. In the scaffolded app, GET /api/v1/billing was declared but granted to no role; tulipes routes listed it as reachable by — nobody, and a guest request got 401 with the code UNAUTHENTICATED. Core reads only req.auth, so swapping JWT for sessions or mTLS changes the auth module and nothing else.

Check permissions against grantsnode · acl/main.mjs

Press Run to see which role may call which endpoint, then follow the “Try this” steps at the top of main.mjs. acl.mjs is the grant grammar ported from @tulipes/core (src/acl/rai-id.ts).

ConsoleNot run yet
Output appears here.

One codebase for the API server, the queue worker and scripts

The same app boots as a backend (HTTP), a worker (queue consumers) or a script. All three run phases 1 to 10 identically, so a maintenance script sees exactly the configuration, models and ACL the server has, not an approximation of it.

app.tsTypeScript
import { boot } from "@tulipes/core/boot"; await boot({ rootDir: import.meta.dirname, mode: "backend" });

A worker with nothing to consume is treated as a misconfigured deployment rather than a process that idles quietly — the minimal app above has no queues provider, and starting it as a worker failed immediately with the message in the comment.

Generate OpenAPI 3.1 and Postman from Express routes, without a database

tulipes routes, tulipes roles and tulipes spec run an inspection boot: the normal pipeline with nothing acquired. Environment values are optional, providers are stubs that throw if used, bootstrap and listen() are skipped. No .env, secret or running service is needed, so CI can document an API it cannot start.

Terminal
$ pnpm tulipes routes4 endpoint(s)POST  agent:web:invoke  /api/v1/agent/tools/:name  agent  guestGET   agent:web:tools   /api/v1/agent/tools        agent  guestGET   hello:web:health  /health                    hello  guestGET   hello:web:read    /api/v1/hello              hello  guest$ pnpm spec  wrote docs/openapi.json  wrote docs/collection.json4 endpoint(s) across 2 folder(s): agent, hello

The OpenAPI 3.1 document carries the permission and the roles that reach each operation (x-tulipes-permission, x-tulipes-roles), alongside the Postman collection. Because inspection runs the same routers through the same scan as a serving process, the generated test that compares both documents passes by construction — and fails the moment a routes file starts reading a database while building its router.

Expose AI agent tools over MCP (Model Context Protocol)

Tulipes does not turn routes into agent tools. A module lists what it offers to agents in agent/*.agent.ts: each action has an id that is also an ACL permission, a description written for a model, a Zod input schema and a handler. Calls go through the same gate as a route — ACL on the id, then the schema, then the handler.

modules/hello/agent/hello.agent.tsTypeScript
import { action, defineAgent } from "@tulipes/core/agent";import { z } from "zod/v4"; export default defineAgent({  summary: "Says hello.",  actions: [    action({      id: "hello:web:greet",      description: "A greeting, optionally addressed to someone",      readOnly: true,      input: z.object({ name: z.string().optional().describe("Who to greet") }),      handler: (ctx, { name }) => ({ message: name ? `Hello, ${name}` : "Hello" }),    }),  ],});
Output reformatted across lines for readingShell
$ curl -s localhost:3000/api/v1/agent/tools{"success":true,"data":{"app":{"name":"my-api"},"authorization":"acl", "tools":[{"name":"hello_web_greet","module":"hello","permission":"hello:web:greet",   "description":"A greeting, optionally addressed to someone",   "input_schema":{"type":"object","properties":{"name":{"description":"Who to greet","type":"string"}},   "additionalProperties":false},"read_only":true}]},"errors":[],"meta":{}}$ curl -s -X POST -H 'content-type: application/json' -d '{"name":"world"}' localhost:3000/api/v1/agent/tools/hello_web_greet{"success":true,"data":{"message":"Hello, world"},"errors":[],"meta":{}}

Generated apps include mcp.ts and @tulipes/mcp, which serves the same manifest to Claude Code or any client of the Model Context Protocol. It is the mechanism behind the k13 tools mentioned at the top: an agent sees exactly the actions its role may call, and nothing derived from the HTTP surface by accident. Every generated app also ships an AGENTS.md, a CLAUDE.md and task-scoped skills under .claude/skills/, so a coding agent learns the conventions — declare variables instead of reading process.env, never call mongoose.model() — before it writes a line.

Graceful shutdown in Node.js, in the right order

Express's own graceful shutdown guide asks an app to stop accepting requests on SIGTERM, finish the ones in flight, release its resources and exit. Tulipes does that for every module. Work stops first and resources close last, all under one shared deadline — 10 seconds unless boot() is given shutdownTimeoutMs (src/boot/boot.ts):

  1. ctx.signal aborts, so long-running work such as an SSE stream or a poller ends while everything it needs is still alive.
  2. The HTTP server closes while queue providers drain at the same time; models stay connected.
  3. Bootstrap cleanups run in reverse load order. A failed or stuck cleanup is reported without blocking the ones after it.
  4. Providers are disposed in the reverse order they were acquired, the time zone is restored, and the process exits 0.
modules/billing/bootstrap/heartbeat.bootstrap.tsTypeScript
import type { RuntimeCtx } from "@tulipes/core/boot"; export default async function heartbeat(ctx: RuntimeCtx) {  const timer = setInterval(() => console.log("billing is alive"), 30_000);  ctx.signal.addEventListener("abort", () => clearInterval(timer)); // shutdown began  return () => console.log("billing cleanup ran");                  // after traffic drains}

Tulipes vs NestJS vs plain Express

All three can build the same API. They differ in where structure comes from and when mistakes surface. NestJS organises code with decorators and a dependency-injection container and runs on Express by default; plain Express leaves every structural choice to you. Tulipes keeps plain Express handlers and gets its structure from file conventions and JSON contracts that are checked at boot.

Express 5 aloneNestJSTulipes
StructureYours to designModules, providers and controllers via decorators and DIFolders and file names; plain functions of ctx
Request handlersExpressController methodsExpress, untouched
Env validationBring a library@nestjs/config schema, checked at bootstrapDeclared per module in JSON, typed by codegen, checked at boot
Other boot-time checksNoneDependency injection graphModule graph, providers, settings, i18n, ACL, every route
Unguarded routeSilentDepends on guards you applyBoot refused
PermissionsBring a libraryGuardsRAI id per route and per agent action
OpenAPIBring a library@nestjs/swagger decoratorsFrom rai() schemas, generated offline
Agent toolsNot coveredNot coveredagent/*.agent.ts, served over MCP (experimental)
MaturityVery matureMature, large ecosystemYoung: 0.x, small user base
How Tulipes compares with plain Express and NestJS on the points this article covers

Choose Tulipes if you want Express's handler model with a framework that refuses broken configurations, and you are comfortable on a young 0.x line. Choose NestJS if you want a large ecosystem and DI today. Choose plain Express for a small service where a framework is more weight than help.

What Tulipes does not promise yet

The project is unusually frank about this, and you should be too before adopting it. From its own support policy and this test run:

  • Node.js 24 only. The CLI refuses other lines through engines; CI covers Node 24 on Ubuntu, and macOS for the minimal starter. Windows is not claimed. Node 24 is an LTS line supported until the end of April 2028 (Node.js releases).
  • No published production evidence. The support policy states there is no load, backup-restore or multi-replica evidence yet, and no independent security review.
  • Three providers. Models mean Mongoose and MongoDB, queues mean BullMQ and Redis, sockets mean Socket.IO. The provider seam is designed for others, but none exist yet.
  • Private source repository. The packages are MIT-licensed and each npm tarball ships the compiled JavaScript with type declarations, but the Git repository, issues and CI need access.
  • Pre-1.0 versioning. A 0.x minor may change public contracts, with a migration guide and a tulipes migrate command — 0.11 and 0.12 both shipped one.

For scale: installing @tulipes/core 0.13.0 alone with npm 10 on Node 24 added 71 packages and 12 MB to node_modules in this run. The full minimal app, with TypeScript, Vitest and the CLI as dev dependencies, came to 131 MB.

Questions people ask about Tulipes

What is Tulipes (tulipesjs)?

Tulipes is an MIT-licensed TypeScript framework for building APIs, queue workers and scripts on Express 5 and Node.js 24. Features live in module folders, and the app refuses to start when an environment variable, module dependency, permission or route declaration is wrong, listing every problem in one report. Install it with npm install -g @tulipes/cli.

Is Tulipes a replacement for Express?

No. It runs on Express 5 and leaves req, res, routers and middleware untouched. It adds res.respond() for a single response envelope, req.t for translations, req.auth and the rai() declaration, and owns the boot and shutdown around them.

How is Tulipes different from NestJS?

NestJS structures an app with decorators and dependency injection, and its @nestjs/config module validates environment variables at bootstrap. Tulipes keeps plain Express handlers, takes its structure from folders and JSON contracts, and extends boot-time checks beyond the environment to the module graph, permissions and every route. NestJS is far more mature; Tulipes is on a 0.x line.

Which Node.js and Express versions does Tulipes support?

Node.js 24.x and Express 5. The CLI's engines field is >=24.0.0 <25, and CI tests Node 24 on Ubuntu.

Does Tulipes need MongoDB or Redis?

Only if you declare them. The minimal preset needs neither and boots with no services at all. MongoDB arrives with @tulipes/mongoose and Redis with @tulipes/bullmq, declared under tulipes.providers in the app's package.json.

How does Tulipes validate environment variables?

Each module declares its variables in package.json with a key, a type (string, number, boolean, enum, url or secret) and either required or a default. At boot every value is resolved from process.env, then .envs/.env.<APP_ENV>, then the default, coerced to its type, and every failure is reported in one error.

Can I generate an OpenAPI spec from Express routes with Tulipes?

Yes. Add Zod schemas to a route's rai() and run tulipes spec --openapi docs/openapi.json --postman docs/collection.json. It writes an OpenAPI 3.1 document and a Postman collection from an inspection boot, so it needs no database, secret or .env file.

Can AI agents call a Tulipes app?

Yes, through actions declared in agent/*.agent.ts, served over HTTP by the generated agent module and over the Model Context Protocol by @tulipes/mcp. Each action is gated by the ACL like a route. This part of the framework is experimental.

Is Tulipes production ready?

Its API is stable on the 0.13 line and it runs this blog's backend, but the project itself states it has no published load or failure-recovery evidence. Treat it as you would any young framework: pilot it on a service you can afford to move.

Where to start

Run tulipes init, then break something on purpose — unset a variable, forget a rai(), grant a role that does not exist — and read what the boot tells you. That report is the framework's whole argument: problems are cheapest before the first request, and cheapest of all when you see every one of them at once.

Sources and further reading

Found this useful? Pass it on.

Written by

Kemo

Full-stack developer. Writes about Node.js backends, small infrastructure and tools that stay out of the way.

Discussion

Join the discussion

Sign in with Google, Apple or an email code to comment and reply.

More in Node.js

One practical article a week

Follow k13 in any RSS reader. No account needed.