Skip to content

Package reference

Mirrors the package README (single source). Install @basaltkit/express v2.0.0 — npm · source.

<p align="center"> <a href="https://basaltkit-docs.pages.dev"> <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440"> </a> </p>

@basaltkit/express ​

Basalt adapter for Express: the same typed routes, enrichers, and guards you'd use in Fastify or Hono, running on an Express server. You need it when you already use Express (or want its huge middleware ecosystem) and want Basalt's validation, per-request context, and standardized errors.

What this module solves ​

Express is Node.js's best-known HTTP server — the program that receives HTTP requests (messages like "create this project") and returns responses. But Express, by itself, doesn't validate data, doesn't type anything in TypeScript, and every project invents its own error format.

This module connects Express to Basalt. Routes (address + method, e.g. POST /echo) are defined with the route() function from @basaltkit/http, in a neutral format with Zod schemas for validation. The adapter converts each Express request into that neutral format, runs the shared pipeline (validation, enrichers — functions that enrich the request context, like resolving the tenant — and guards — functions that can reject the request, like authentication), and converts errors into JSON responses with a stable format.

The strong point: portability. A route written for this adapter runs unchanged on @basaltkit/fastify and @basaltkit/hono — the three adapters are equals. Routes, enrichers, guards, the per-request context, standardized errors, the neutral 404, ETags (meta.etag), SSE, per-route rate limits and the boot-time guarded-meta check all behave identically. The neutral edge plugins (security, health, metrics, tracing, OpenAPI) from @basaltkit/http work here exactly the same.

Installation ​

bash
pnpm add @basaltkit/express @basaltkit/core @basaltkit/http express zod

express (version 4.19+ or 5) is a peer dependency — install it yourself. zod is required for the route schemas.

Get started in 5 minutes ​

Step 1 — install the packages (command above).

Step 2 — create a server.ts file:

ts
import { createApp } from '@basaltkit/core'
import { route } from '@basaltkit/http'
import { EXPRESS, expressPlugin } from '@basaltkit/express'
import { z } from 'zod'

// 1. Define a route: method, URL, validation, and handler (the function that responds).
const echo = route({
  method: 'POST',
  url: '/echo',
  body: z.object({ n: z.number() }), // the body must have a number n
  async handler({ body, reply }) {
    // body.n is already validated and typed as number
    return reply.code(201).send({ doubled: body.n * 2 })
  },
})

// 2. Create the Basalt app with the Express plugin and start it.
const app = await createApp({ plugins: [expressPlugin({ routes: [echo] })] }).boot()

// 3. Get the Express app from the container and have it listen on a port.
app.container.get(EXPRESS).listen(3000)
console.log('Listening on http://localhost:3000')

Step 3 — run and test:

bash
npx tsx server.ts
curl -X POST http://localhost:3000/echo \
  -H 'content-type: application/json' \
  -d '{"n":21}'
# → {"doubled":42}   (status 201)

curl -X POST http://localhost:3000/echo \
  -H 'content-type: application/json' \
  -d '{"n":"nope"}'
# → 400 {"error":{"code":"HTTP_VALIDATION","part":"body","issues":[...]}}

> The plugin enables express.json() and express.urlencoded({ extended: false }) for you — you don't need to configure body parsing. (The urlencoded parser is what makes HTML forms and the SAML ACS binding work.)

Usage guide ​

Routes with params, query, and errors ​

ts
import { HttpError, route } from '@basaltkit/http'
import { z } from 'zod'

const hello = route({
  method: 'GET',
  url: '/hello/:name', // :name is a dynamic URL parameter
  params: z.object({ name: z.string() }),
  async handler({ params }) {
    return { hello: params.name }
  },
})

const boom = route({
  method: 'GET',
  url: '/boom',
  async handler() {
    // Intentional error: turns into a 418 response with a stable code
    throw new HttpError(418, 'TEAPOT', "I'm a teapot")
  },
})

The error format is identical to the other adapters: { error: { code, message, ... } }. Unexpected errors respond with 500 and INTERNAL_ERROR, without exposing internal details.

Guarded route meta — the boot check ​

If a route declares meta.auth, meta.can or meta.teamRole and no registered plugin enforces that key, the route would serve unprotected. expressPlugin refuses to boot: it calls assertRoutesGuarded() in its boot phase and throws UnguardedRouteMetaError (code HTTP_UNGUARDED_ROUTE_META), naming every offending route and key, before a single request is served.

Fix it by registering the enforcing plugin (auth → authPlugin, can → permissionsPlugin, teamRole → teamsPlugin). If protection really does happen at an outer edge (a gateway that authenticates first), waive it explicitly:

ts
expressPlugin({ routes, allowUnguardedMeta: true })      // waive every key
expressPlugin({ routes, allowUnguardedMeta: ['auth'] })  // waive one key

Fastify and Hono run the identical check with the identical option.

The neutral 404 ​

Unmatched routes get the same JSON body on every adapter — { "error": { "code": "NOT_FOUND", "message": "Route not found." } } (NOT_FOUND_RESPONSE from @basaltkit/http) — instead of Express's HTML default, which fingerprints the framework. It is mounted last, at app:booted. Pass notFound: false to keep Express's own handling, e.g. when your app mounts its own catch-all afterwards.

Streaming a body — stream() ​

A handler returning stream(source, { contentType, contentLength?, filename? }) from @basaltkit/http is sent with pipeline(), never buffered. The first chunk is pulled before anything is written, so a source that fails immediately still becomes the usual JSON error; headers go out with the first byte; a client disconnect destroys the source; and a failure after the headers destroys the response (reported once through onError as STREAM_FAILED) rather than appending anything to a partly sent body. HEAD answers with the headers and no body. Bound long downloads with server.setTimeout(). Same handler code as on Fastify and Hono.

Streaming — SSE ​

A handler returning sse(producer) from @basaltkit/http is streamed straight onto the Express response (res.writeHead(200, SSE_HEADERS), flushed at once so a quiet stream still opens), keeping the CORS/security/rate-limit/x-request-id headers set before it, with client disconnects relayed to stream.onClose(). Same handler code as on Fastify and Hono.

Wire behaviour — the same as Fastify and Hono ​

  • A handler that returns a string is served as text/plain; charset=utf-8 — not Express's text/html default (a handler echoing its input would be a reflected XSS). Serve HTML by saying so: reply.header('content-type', 'text/html; charset=utf-8').send(html).
  • JSON is parsed for application/json or a +json type only (never text/plain; application/json); malformed JSON is 400 BAD_REQUEST; an empty JSON body is undefined.
  • Body limit: 1 MiB by default (bodyLimit), like the other adapters.
  • On the app the plugin creates, routing is case-sensitive and strict (/Admin, /admin/ do not reach /admin) and the query parser is simple (?a=1&a=2 → ['1', '2']). An app you pass in keeps its own settings.
  • request.ip is the socket address unless you set app.set('trust proxy', …) on an app you pass in — do that only behind a proxy you control.
  • An SDK error that merely carries a status/type is a 500, not a 400: only body-parser's own errors are mapped to 400/413/415.

Uploads — upload() ​

body: upload({ maxBytes, maxFiles, allowedTypes? }) from @basaltkit/http gives a route a streamed multipart/form-data body, with no multer. express.json() and express.urlencoded() never read multipart, so the adapter hands the untouched req stream to the neutral parser after enrichers and guards ran. If you bring your own app, don't mount a global multipart middleware in front of upload routes: it would consume the stream first, and those requests would fail with 400 MALFORMED_MULTIPART. See the @basaltkit/http README.

Raw request bodies — rawBody() ​

body: rawBody({ maxBytes? }) from @basaltkit/http gives a route the untouched request bytes — what a webhook signature (Stripe, Paddle, Lemon Squeezy, Dropbox, GitHub) is computed over. JSON.stringify of a parsed object is a different message, so verifying against it fails every genuine delivery.

Express is the adapter where the neutral marker alone cannot win, because express.json() is mounted on the whole app. Two things close the gap, and both are installed only when a rawBody() route exists, so an app without one is byte-for-byte unchanged:

  1. express.json() and express.urlencoded() get a type filter that returns false for rawBody() paths, so body-parser never reads them — the stream reaches the pipeline unread, after enrichers and guards, exactly like upload().
  2. They also get a verify hook (captureRawBody) that keeps the buffer, as a second line for anything the path matching could not predict.

The caveat, honestly: if you bring your own app (expressPlugin({ app })) with express.json() already mounted, body-parser consumes the stream before any Basalt route runs and the original bytes are gone. Give it the hook:

ts
import express from 'express'
import { captureRawBody, expressPlugin } from '@basaltkit/express'

const app = express()
app.use(express.json({ verify: captureRawBody }))
app.use(express.urlencoded({ extended: false, verify: captureRawBody }))

expressPlugin({ app, routes })

The widespread verify: (req, _res, buf) => { req.rawBody = buf } convention is honoured too, so an app already doing that needs no change. With neither, the route answers 500 RAW_BODY_UNAVAILABLE — a deliberate refusal, never a reconstruction. The same applies to registerRoutes() used without the plugin: it mounts routes, not parsers.

See the @basaltkit/http README.

Enrichers and guards (authentication, tenancy, …) ​

Plugins register these functions in the container's metadata "buckets"; the adapter applies them to every route. Real example (from the package's tests) — a tenancy-style enricher and an auth-style guard:

ts
import { createApp, definePlugin, ensureMetadata, tryCtx } from '@basaltkit/core'
import { HttpError, route, type RequestEnricher, type RouteGuard } from '@basaltkit/http'
import { EXPRESS, expressPlugin } from '@basaltkit/express'
import { z } from 'zod'

// Enricher: runs before everything else and attaches the tenant to the request context.
const enricher: RequestEnricher = ({ request, context }) => {
  const tenant = request.headers['x-tenant-id']
  if (typeof tenant === 'string') (context as { tenant?: unknown }).tenant = { id: tenant }
}

// Guard: rejects the request by throwing an error. Reads the route's meta.
const guard: RouteGuard = ({ route: def, request }) => {
  if (def.meta?.['auth'] && !request.headers['authorization']) {
    throw new HttpError(401, 'AUTH_REQUIRED', 'Authentication required.')
  }
}

const myPlugin = definePlugin({
  name: 'my:http',
  register({ container }) {
    const metadata = ensureMetadata(container)
    metadata.add('http:enrichers', enricher)
    metadata.add('http:guards', guard)
  },
})

const secure = route({
  method: 'GET',
  url: '/secure',
  meta: { auth: true }, // the guard reads this
  async handler() {
    const tenant = (tryCtx() as { tenant?: { id: string } })?.tenant?.id ?? null
    return { ok: true, tenant }
  },
})

const app = await createApp({ plugins: [myPlugin, expressPlugin({ routes: [secure] })] }).boot()
app.container.get(EXPRESS).listen(3000)

Without Authorization → 401 AUTH_REQUIRED; with the x-tenant-id: acme header the handler sees tenant: 'acme' via context.

Neutral edge plugins ​

Imported from @basaltkit/http and work on Express without changes:

ts
import { createApp } from '@basaltkit/core'
import { healthPlugin, metricsPlugin, route, securityPlugin } from '@basaltkit/http'
import { EXPRESS, expressPlugin } from '@basaltkit/express'

const ping = route({ method: 'GET', url: '/ping', async handler() { return { pong: true } } })

const app = await createApp({
  plugins: [
    expressPlugin({ routes: [ping] }),
    securityPlugin({ rateLimit: { limit: 100, windowMs: 60_000 } }), // secure headers + 429 above the limit
    healthPlugin({ checks: { db: () => ({ ok: true }) } }),          // GET /livez and /readyz
    metricsPlugin(),                                                  // GET /metrics (Prometheus)
  ],
}).boot()
app.container.get(EXPRESS).listen(3000)

All the options for these plugins are documented in the @basaltkit/http README.

Bring your own Express app ​

If you already have an Express app with your own middleware, pass it to the plugin:

ts
import express from 'express'
import { expressPlugin } from '@basaltkit/express'

const myApp = express()
// ... your middleware here ...
expressPlugin({ app: myApp, routes: [] })
// Note: the plugin still adds express.json() and express.urlencoded() (limit: bodyLimit),
// but leaves this app's routing and query-parser settings alone.

Advanced: registerRoutes() without the plugin ​

Mount Basalt routes on an Express app directly, without the Basalt lifecycle:

ts
import express from 'express'
import { route } from '@basaltkit/http'
import { registerRoutes } from '@basaltkit/express'

const app = express()
app.use(express.json()) // without the plugin, JSON parsing is your responsibility
const ping = route({ method: 'GET', url: '/ping', async handler() { return { pong: true } } })
registerRoutes(app, [ping]) // container, enrichers, and guards are optional
app.listen(3000)

In this mode each handler already handles its own errors (the wrapper responds with toErrorResponse), but there are no edge plugins or route registration for OpenAPI/CLI.

API reference ​

expressPlugin(options?) → Basalt plugin (basalt:express) ​

OptionTypeRequired?DefaultDescription
routesBasaltRoute[]No[]Routes (created with route() from @basaltkit/http) to mount.
allowUnguardedMetaboolean | string[]Nofail loud at bootWaives the boot check that every route declaring security meta (auth, can, teamRole) has a registered guard enforcing it (UnguardedRouteMetaError otherwise). true waives everything (edge/gateway auth); an array waives specific keys. Never waives the route-meta validators plugins register (InvalidRouteMetaError).
bodyLimitnumberNo1048576 (1 MiB)Largest JSON/form body parsed; larger answers 413 PAYLOAD_TOO_LARGE. Same default as Fastify and Hono (body-parser alone: 100 KiB).
appExpressNonew express() (case-sensitive, strict routing, simple query parser)Bring your own Express app; either way, express.json() and express.urlencoded({ extended: false }) are added.
notFoundbooleanNotrueServe NOT_FOUND_RESPONSE (the neutral JSON 404) for unmatched routes, mounted last. Set false to keep Express's HTML default or your own catch-all.
errorHandlerbooleanNotrueMount a final (err, req, res, next) middleware that answers body-parser and pre-hook errors with the neutral JSON envelope instead of Express's HTML page (which includes the stack trace unless NODE_ENV=production). Set false only if you mount your own error handler after boot.

Behavior: registers the Express app under the EXPRESS token and an HttpServerCollector under the HTTP_SERVER token. On the app:booted event it mounts everything in the order Express requires: after-hooks middleware (metrics/tracing, run once on finish or close — so an abandoned response is seen too; a failing hook is reported as AFTER_HOOK_FAILED) → pre-hooks middleware (security/CORS/rate limit; if one of them responds, the route doesn't run) → Basalt routes → extra routes from edge plugins (/livez, /metrics, …). Publishes the routes in the 'http:routes' metadata bucket for OpenAPI/CLI/SDK.

> Note: unlike fastifyPlugin, this plugin has no shutdown step — Basalt never calls listen() for you, so closing the server it returns is your responsibility.

Errors ​

ErrorCodeHTTPWhen
RequestValidationErrorHTTP_VALIDATION400body/query/params failed its Zod schema. Response carries part + issues[].
HttpError(status, code, message)yoursyoursThrown deliberately from any layer.
UnguardedRouteMetaErrorHTTP_UNGUARDED_ROUTE_META— (boot)A route declares a guarded key (auth/can/teamRole/scopes/subscribed/feature) with no guard enforcing it. Waive with allowUnguardedMeta.
InvalidRouteMetaErrorHTTP_INVALID_ROUTE_META— (boot)A plugin's route-meta validator (http:meta-validators) refused a value — e.g. teamsPlugin and an unknown meta.teamRole. Not waivable.
—NOT_FOUND404No route matched (unless notFound: false).
—BAD_REQUEST400The body could not be parsed (malformed JSON, corrupt encoding).
—PAYLOAD_TOO_LARGE413The body exceeded bodyLimit (1 MiB by default).
—UNSUPPORTED_MEDIA_TYPE415Unsupported body charset or content encoding.
—RATE_LIMITED429securityPlugin's limiter rejected the request.
—INTERNAL_ERROR500Any other thrown error. The real message never reaches the client.

All of these are produced by the shared @basaltkit/http pipeline, so the bodies are byte-identical to Fastify's and Hono's.

EXPRESS ​

Dependency-injection token (Token<Express>): app.container.get(EXPRESS) returns the Express app so you can call listen(port) or add middleware.

registerRoutes(app, routes, container?, enrichers?, guards?) ​

ParameterTypeRequired?DefaultDescription
appExpressYes—Express app to mount on.
routesBasaltRoute[]Yes—Routes to mount.
containerContainerNo—DI container; without it there's no per-request scope or enrichers/guards.
enrichersRequestEnricher[]No[]Functions that enrich the context before the guards.
guardsRouteGuard[]No[]Functions that can reject the request (by throwing an error).

What to import from where ​

This package only exports expressPlugin, registerRoutes, captureRawBody, EXPRESS, and ExpressPluginOptions. Everything else — route, HttpError, RequestValidationError, NOT_FOUND_RESPONSE, sse, securityPlugin, RedisRateLimitStore, healthPlugin, metricsPlugin, tracingPlugin, openapiPlugin, escapeHtml/pageCsp, types like RequestEnricher/RouteGuard — is imported from @basaltkit/http. (Unlike @basaltkit/fastify, this package re-exports nothing; that is a naming choice, not a capability gap.)

Common errors and solutions (FAQ) ​

"Cannot find module 'express'." Express is a peer dependency: pnpm add express.

"body arrives undefined in the handler." The client has to send Content-Type: application/json (or a +json type); without it the body is not parsed. An empty JSON body is undefined too.

"My HTML page shows as text." Strings are text/plain unless the handler sets content-type — set text/html; charset=utf-8 explicitly.

"/Users or /users/ answers 404." Routing is case-sensitive and strict on the app the plugin creates (as on Fastify and Hono). Bring your own app to keep Express's defaults.

"I tried import { route } from '@basaltkit/express' and it failed." The route() function isn't exported from this package — import it from @basaltkit/http (it's neutral on purpose: the same route runs on Fastify and Hono).

"400 HTTP_VALIDATION on a GET with a correct query." In Express's query everything arrives as text — use z.coerce.number() / z.coerce.boolean() in your schemas.

"The edge plugins don't respond (/metrics gives 404)." They're mounted on the app:booted event: make sure you call await createApp({...}).boot() before listen() and that expressPlugin is in the plugin list (it's the one that registers HTTP_SERVER).

"How do I close the server in a test?" Keep the return value of listen(): const server = app.container.get(EXPRESS).listen(0) and at the end server.close() followed by await app.shutdown().

How it connects to other modules ​

  • @basaltkit/core — expressPlugin is a Basalt plugin (definePlugin) in the createApp → boot lifecycle; it uses the Container (tokens EXPRESS, HTTP_SERVER), the metadata buckets, and the per-request context (ctx()/tryCtx()), available at any depth of the code.
  • @basaltkit/http — provides route(), the runRoute() pipeline (validation, enrichers, guards), toErrorResponse(), and the edge plugins. This adapter simply converts Express's Request/Response into the neutral HttpRequest/HttpReply.
  • @basaltkit/fastify / @basaltkit/hono — sibling adapters: the same routes, enrichers, guards, and edge plugins run on any of them unchanged; switching frameworks is just switching plugins.
  • @basaltkit/auth / @basaltkit/tenancy / @basaltkit/permissions — register guards/enrichers in 'http:guards'/'http:enrichers' and read the routes' meta (e.g. meta: { auth: true }); this adapter applies them automatically.
  • @basaltkit/sdk and the CLI — consume the 'http:routes' bucket (routes + Zod schemas) that this plugin publishes.
  • @basaltkit/testing — createTestApp({ adapter: 'express' }) runs your suite against this adapter (it listens on an ephemeral 127.0.0.1 port and fetches, because Express has no in-process inject).

Guides: Adapters · Migrating from Express · Testing · Security

Released under the MIT License.