Migrate from Express.js to DaloyJS
This is the long version. If you have an existing Express app and you want to move it to DaloyJS, read this top to bottom once, then keep it open as a reference while you work. It assumes you know Express a little, and assumes nothing about DaloyJS. Every concept is mapped from the Express idea you already have to the DaloyJS equivalent, with before/after code for each one.
The five W's (and one H), up front
Before any code, let's answer the questions you should be asking.
What is this migration, really?
Express is a routing + middlewareframework. An Express app is, in its own words, "essentially a series of middleware function calls." You wire callbacks of the shape (req, res, next) onto paths, mutate res, and eventually call something like res.send() to end the cycle.
DaloyJS is a contract-first framework. Instead of imperatively pushing bytes onto a mutable response, you declare each endpoint: its method, path, the schemas for its inputs (params, query, headers, body), and the schemas for each possible response. Your handler is a pure-ish async function that returns a { status, body } object. From that single declaration DaloyJS validates requests and responses, generates an OpenAPI document, serves interactive docs, and produces a fully typed client SDK, all without extra code.
So the migration is not a find-and-replace. It is a small shift in mental model: from "mutate res and call next" to "declare a contract and return a value." Once that clicks, the rest is mechanical.
Why would you migrate at all?
- You want OpenAPI + typed clients for free. In Express you bolt on
swagger-jsdoc, hand-write JSDoc, and hope it stays in sync. In DaloyJS the spec is the route, so it never drifts, and a typed SDK falls out ofpnpm gen. - You want validation that the type system trusts. DaloyJS validates with Standard Schema (Zod, Valibot, ArkType, ...) and infers the handler's
params/query/bodytypes from those schemas. No morereq.body as any. - You want secure defaults instead of a TODO list. Express ships almost nothing; you remember to add
helmet, a rate limiter, a body limit, a request timeout, and you hope nobody forgets. DaloyJS ships secure-by-default body limits, request timeouts, header sanitization, and one-linesecureHeaders()/rateLimit()helpers. - You want to run the same app everywhere.Express is tied to Node's
httpmodule. DaloyJS is built on web-standardRequest/Responseand ships adapters for Node, Bun, Deno, Cloudflare Workers, Vercel, and more. - You want zero runtime dependencies. The Express dependency tree is dozens of packages.
@daloyjs/corehas no runtime dependencies, which shrinks your supply-chain attack surface.
If none of those matter to you, that is a legitimate answer too, see the next question.
When should you migrate (and when should you not)?
Good times to migrate:
- You are starting a new service or a new API surface (greenfield is the easiest case, just start in DaloyJS).
- You are about to add OpenAPI docs or a client SDK to an Express app anyway.
- You keep getting bitten by untyped
req.body/ runtime validation bugs. - You want to deploy to the edge or serverless and Express's Node coupling is in the way.
- You are doing a security pass and want defaults instead of a checklist.
Times to be cautious or stay:
- You lean heavily on server-rendered HTML via view engines (EJS, Pug, Handlebars). DaloyJS is API-first; it can return HTML, but it is not a templating framework. See Views & template engines below.
- You depend on a niche Express middleware with no equivalent and no appetite to port it. Most have equivalents (see the mapping table), but check yours first.
- The app is in maintenance-only mode and stable. Migration has a cost; spend it where there is upside.
You do not have to migrate in one weekend. The incremental strategy below lets the two frameworks run side by side while you move routes over one at a time.
Where does DaloyJS fit?
DaloyJS targets JSON/HTTP APIs and services: REST backends, BFFs, internal microservices, webhook receivers, serverless functions, edge APIs. If your Express app is mostly res.json(...), you are squarely in the sweet spot. If it is mostly res.render(...), weigh the where-to-use guide first.
Who should do this?
Any TypeScript-comfortable developer. You do not need to be a framework expert. DaloyJS is TypeScript-first, so the biggest prerequisite is a tsconfig.json and being okay writing types (the framework writes most of them for you). If your Express app is plain JavaScript, budget a little time to add TypeScript, it pays for itself immediately because the contract-first model leans on inference.
How, in one sentence?
Stand up an empty DaloyJS app, port your middleware to hooks/plugins, rewrite each app.METHOD(path, handler) as an app.route({ ... }) declaration that returns a value instead of mutating res, replace your error middleware with thrown HttpErrors, and swap app.listen() for a runtime adapter. The rest of this page is that sentence, expanded.
The mental model, side by side
Hold these two pictures in your head. Everything else follows from the difference.
Key differences to internalize:
- You return, you don't mutate. There is no
resto push onto and nonext()to forget. A handler returns{ status, body, headers? }, and the status code is type-checked against your declaredresponses. - Inputs are validated before your handler runs. If the body fails its schema, the client gets a problem+json 422 automatically, your handler is never called.
- Order is structured, not positional. Express runs middleware in the exact order you call
app.use. DaloyJS runs hooks at named lifecycle points (global, then group, then route), which is more predictable and removes a whole class of "why didn't my middleware run" bugs.
Before you start
Prerequisites
- Node.js >= 24 (DaloyJS also runs on Bun, Deno, Workers, etc.).
- pnpm (recommended), or npm/yarn if you must.
- TypeScript. If your app is JS, plan to convert at least the new entrypoint.
Install
Either scaffold a fresh project with create-daloy and copy your logic into it, or add DaloyJS alongside Express in your existing repo for an incremental migration:
See Installation for the full setup, including the package.json scripts and tsconfig.json DaloyJS expects.
Step 1: Bootstrap the app
The classic Express hello-world becomes an App instance plus a runtime adapter. Notice that DaloyJS asks you to set a body limit and request timeout up front, those are secure defaults you would have had to remember to add in Express.
That is the whole shape of a DaloyJS app. The rest of this guide is just filling in routes and hooks. Want the interactive docs UI too? Add docs: true to the App options and you get GET /docs, GET /openapi.json, and GET /openapi.yaml for free, no Express equivalent exists without extra packages.
Step 2: Routing
Every app.get / app.post / etc. becomes one app.route(...) call. The HTTP method moves inside the object as a method field. Each route needs a unique operationId (this is what names the generated client method and the OpenAPI operation).
Supported methods: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. HEAD is auto-derived from a matching GET when you don't declare it. See Routing for the full reference.
Path parameters
Express and DaloyJS use the same :name syntax in the path. The difference is where the value shows up: Express puts it on req.params (always string); DaloyJS puts it on ctx.params, and if you attach a schema, it is parsed and typed for you.
Route path differences to know: Express 5 uses path-to-regexp v8, which supports named wildcards like /files/*filepath and brace-wrapped optional segments like /:file{.:ext}. Note that Express 5 no longer supports inline regular-expression characters inside path strings (they are reserved); you can still pass a JavaScript RegExp object as the path. DaloyJS uses a trie/radix router with the conventional :param syntax and does not accept regex paths. If you rely on a regex route or a complex wildcard, model it as a single param plus validation in the handler, or split it into explicit routes. This is intentional: predictable, traversal-safe matching beats arbitrary regex on a hot path. Path traversal (..) and empty segments are rejected by the router before your handler runs.
Query strings and request bodies
In Express you read req.query and req.body (after wiring up express.json()), both untyped, both unvalidated. In DaloyJS you declare schemas and the validated, typed values arrive on ctx. There is no separate body-parser step: JSON bodies are parsed automatically and checked against your request.body schema.
If a request fails validation, DaloyJS short-circuits with an RFC 9457 problem+json 422 response before your handler runs, so the body is guaranteed valid inside the handler. Full schema reference: Validation.
Step 3: Middleware becomes hooks
This is the biggest conceptual change, so go slow here. An Express middleware is a function (req, res, next) that can do work, optionally mutate req/res, and then either end the response or call next(). DaloyJS replaces the positional chain with named hooks that fire at fixed lifecycle points:
| Lifecycle point | When it runs | Express analogue |
|---|---|---|
onRequest | Earliest, raw Request, before any parsing. | Early app.use middleware. |
beforeHandle | After validation, before your handler. Return a Response to short-circuit. | Auth/guard middleware that may res.status(401).end(). |
afterHandle | Transform the handler's return value. | Response-shaping middleware. |
onError | On the error path, before serialization. Can replace the error response. | Error-handling middleware (err, req, res, next). |
onSend | After the response is built; mutate headers or replace it. | Middleware that rewrites the outgoing response. |
onResponse | Final, fire-and-forget observer. Cannot change anything. | Logging middleware at the end of the chain. |
Hooks compose pipeline-style: global hooks (passed to new App({ hooks }) or via app.use) run first, then group hooks, then per-route hooks. You attach them globally with app.use(...), to a group with app.group(prefix, { hooks }, ...), or to a single route with the route's hooks field.
A logging middleware
An auth guard middleware
In Express a guard either calls next() or ends the response early. In DaloyJS, beforeHandle returns a Response to short-circuit, or returns nothing to continue. Even better: throw a typed error and let the framework render it (see Step 4).
For real authentication you rarely hand-roll this. DaloyJS ships bearerAuth(), basicAuth(), JWT/JWK verifiers, and sessions, see Authentication. Those are drop-in hooks: hooks: bearerAuth({ validate: (t) => ... }).
The built-in & third-party middleware mapping table
Here is the part you actually came for: what to do with each Express middleware you are using today.
| Express middleware | DaloyJS replacement |
|---|---|
express.json() | Built in. Declare a request.body schema; JSON is parsed and validated automatically. |
express.urlencoded() | Parse application/x-www-form-urlencoded in the handler from ctx.request, or use a schema after decoding. Multipart forms: see multipart. |
express.static() | No built-in static server (API-first). Serve assets from a CDN/object store, or front the app with nginx/Caddy. See Static files. |
cors | cors() from @daloyjs/core. app.use(cors({ origin: "https://app.example.com", credentials: true })). |
helmet | secureHeaders(), on by default-grade headers (CSP, HSTS, frame options, nosniff, ...). See Security. |
morgan (logging) | An onResponse hook, or the built-in timing() hook plus your logger. See tracing / metrics. |
express-rate-limit | rateLimit({ windowMs, max }). Redis-backed store available, see Redis rate-limit store. |
cookie-parser | readRequestCookie() to read, serializeCookie() to write. See Cookies & sessions. |
express-session | DaloyJS sessions with secure cookie defaults. |
csurf / CSRF | csrf() hook (fetch-metadata or token strategies). See CSRF protection. |
compression | compression() hook. See compression (note the decompression-bomb guardrails). |
multer (uploads) | Built-in multipart parsing with size/field guards. |
passport / auth | bearerAuth(), basicAuth(), JWT/JWK, or an OIDC provider, see Authentication. |
| ETag / conditional GET | etag() hook. |
Custom (req,res,next) middleware | Port the logic into the matching hook (onRequest/beforeHandle/onSend) and package reusable bundles as plugins. |
For combining hooks conditionally (the equivalent of mounting a middleware on some paths but not others), DaloyJS exports every, some, and except from @daloyjs/core, e.g. apply CSRF everywhere except your webhook routes.
Step 4: Error handling
Express centralizes errors in a special four-argument middleware (err, req, res, next), and you signal errors by calling next(err). DaloyJS replaces both with thrown typed errors plus an optional onError hook. Throw one of the built-in HttpError subclasses (or your own subclass) and the framework renders a consistent RFC 9457 problem+json response with the right status code, and in production it redacts internal details automatically.
Available out of the box: BadRequestError (400), UnauthorizedError (401), ForbiddenError (403), NotFoundError (404), ConflictError (409), PayloadTooLargeError (413), TooManyRequestsError (429), InternalError (500), and more. Need cross-cutting error behavior (custom logging, a Sentry hook, a translated message)? Add a global onError hook, that is your "one error middleware," but it can't be forgotten and it runs at a defined point:
One more nicety: because validation runs before your handler, the "bad input" error path (Express's most common manual if (!valid) return res.status(400)) disappears entirely. DaloyJS returns the 422 for you.
Step 5: Routers become groups (and plugins)
Express express.Router() "mini-apps" mounted with app.use("/prefix", router) map to two DaloyJS tools:
app.group(prefix, opts, fn)for a prefix + shared tags/hooks within the same file.- Plugins (
app.register(plugin, { prefix }) for genuinely modular, encapsulated units in their own files, the real Router-as-module replacement, with Fastify-style encapsulation so a plugin can't leak its middleware into siblings.
Plugins also support app.decorate("db", ...)to inject shared resources (a database client, a logger) into every handler's ctx.state, the clean replacement for Express's habit of hanging things off app.locals or req. Augment the AppState interface and those decorations are fully typed in every handler.
Step 6: Request and response object cheat-sheet
Express gives you fat req and res objects. DaloyJS gives you a typed ctx and you return a value. Here is the translation for the things you reach for most.
Reading the request
| Express | DaloyJS |
|---|---|
req.params.id | ctx.params.id (typed if you add a params schema) |
req.query.q | ctx.query.q (typed via a query schema) |
req.body | ctx.body (typed + validated via a body schema) |
req.get("x-foo") / req.headers["x-foo"] | ctx.request.headers.get("x-foo") or a headers schema -> ctx.headers |
req.method / req.path | ctx.request.method / new URL(ctx.request.url).pathname |
req.cookies.sid (cookie-parser) | readRequestCookie(ctx.request.headers.get("cookie"), "sid") |
req.ip | readRemoteAddress(ctx) (peer address), or resolveClientIp(ctx.request, cfg) for proxy-aware resolution |
Writing the response
| Express | DaloyJS |
|---|---|
res.json(obj) | return { status: 200, body: obj } (JSON inferred) |
res.status(201).json(obj) | return { status: 201, body: obj } |
res.send("text") | return { status: 200, body: "text" } |
res.sendStatus(204) | return { status: 204, body: undefined } |
res.set("x-foo", "bar") | return { status: 200, headers: { "x-foo": "bar" }, body } or ctx.set.headers.set(...) |
res.redirect("/login") | return { status: 302, headers: { location: "/login" }, body: undefined } |
res.cookie("sid", v) | ctx.set.headers.set("set-cookie", serializeCookie("sid", v, {...})) |
res.clearCookie("sid") | ctx.set.headers.set("set-cookie", serializeClearCookie("sid")) |
res.render("view", data) | Return HTML you built yourself (DaloyJS is API-first), see below |
res.download(file) / res.sendFile(file) | Stream the file as the body with content-disposition, see below |
The big win: in Express, res.status(201).json(...) with a status you never documented just works (and silently drifts from your docs). In DaloyJS, returning status: 201 only type-checks if 201 is declared in that route's responses. The compiler keeps you honest.
Cookies and sessions
Express leans on cookie-parser and express-session. DaloyJS gives you primitives plus a first-party session plugin.
For full server-side sessions (login state, rotation, secure cookie defaults), use the session plugin instead of hand-rolling it, and read CSRF protection if you keep cookie-based auth.
Static files and downloads
Express bundles express.static() and res.sendFile() / res.download(). DaloyJS is deliberately API-first and ships no static file server. Recommended approaches, in order:
- Serve static assets from a CDN / object store (S3+CloudFront, R2, etc.). Best for production regardless of framework.
- Put a reverse proxy in front(nginx, Caddy, your platform's edge) that serves
/staticand forwards everything else to DaloyJS. - Stream a specific file from a handler when you need app logic (auth-gated downloads, generated files). Read the file and return it as the body with the right headers, set
content-disposition: attachment; filename="..."to reproduceres.download(). Always sanitize untrusted filenames withsanitizeFilename()/assertSafeRelativePath()from@daloyjs/coreto avoid path traversal.
Views and template engines
If your Express app calls app.set("view engine", "ejs") and res.render(...) a lot, be honest with yourself: DaloyJS is not a templating framework, and forcing server-rendered HTML through it fights the grain. Two sane paths:
- Split the concern. Keep DaloyJS for the JSON API and move the UI to a frontend (Next.js, Astro, plain SPA) that calls your typed client. This is the recommended modern architecture and usually where teams want to end up anyway.
- Render HTML strings yourself for the occasional page. Build the HTML (with any template library you like, or template literals) and return it with a
content-type: text/htmlheader. Good for emails, a status page, or a handful of marketing routes, not for a full server-rendered app.
Step 7: Start the server (and shut it down cleanly)
app.listen() is replaced by a runtime adapter's serve(). On Node that is @daloyjs/core/node, which also wires up graceful shutdown for you.
Deploying somewhere other than a long-running Node process? Swap the import for the matching adapter (Bun, Deno, Cloudflare Workers, Vercel, AWS Lambda, ...), the same appobject runs on all of them. That portability is something Express simply cannot do, because it is bound to Node's http module.
A full before/after example
Here is a small but complete Express API, a tiny book service with listing, fetch-by-id, create, auth, and error handling, followed by its DaloyJS equivalent. This is the shape most real migrations take.
Look at what disappeared: the body-parser line, the manual if (!id || !title) validation, the hand-rolled auth status code, and the catch-all error middleware. Look at what appeared for free: an OpenAPI spec, a docs UI, response validation, and a path to a typed client. That is the trade the migration makes.
Incremental migration (the strangler-fig approach)
You do not have to flip everything at once. The safest way to migrate a large Express app is to strangle it: stand the two apps side by side and move routes across one slice at a time, with a router in front deciding who serves what.
- Put a reverse proxy in front of both.nginx, Caddy, or your platform's router sends already-migrated paths (say
/v2/*) to the DaloyJS process and everything else to the existing Express process. Nothing in either app needs to know about the other. - Migrate by bounded slice, not by file. Move a whole resource (all of
/books) at once so you don't split a feature across two frameworks. Mirror its routes in DaloyJS, point the proxy at the new one, delete the Express version. - Share nothing fragile.Both apps can talk to the same database and the same session store. Keep cookie names, JWT secrets, and session formats identical during the transition so a user's login works no matter which app serves the request.
- Lock behavior with contract tests. Before moving a route, capture its current responses. After moving, assert DaloyJS returns the same thing. The in-process
app.request(...)client (no port needed) makes this fast, see Testing. - Repeat until Express is empty, then delete it. When the last slice is gone, remove the proxy split and the Express dependency tree with it.
If you prefer a hard cut-over instead (small apps, or a quiet maintenance window), scaffold with create-daloy, port everything using this guide, run your test suite against both, and switch DNS/traffic once.
Testing your migration
Every App exposes app.request(input, init?), an in-process client that takes a URL or Request and returns a Response, no server, no port, no second terminal. It is ideal for porting Supertest-style Express tests and for the contract tests in the strangler approach above.
See Testing & contract tests for the full patterns, including snapshotting the OpenAPI document to catch accidental breaking changes during the migration.
Gotchas and FAQ
- "Where did
next()go?" - Nowhere, you don't need it. Continuing the pipeline is the default (a hook that returns nothing just falls through). To stop early, return a
ResponsefrombeforeHandleor throw an error. There is no "forgot to callnext()and the request hangs" failure mode. - "Can I just return a string like
res.send?" - Yes:
return { status: 200, body: "hi" }. Objects are serialized as JSON; strings and buffers are sent as-is. The shape is always{ status, body, headers? }. - "My Express route used a regex path."
- DaloyJS does not accept regex paths by design. Model it as a normal
:paramroute and validate the param's shape with a schema (z.string().regex(...)), or split into explicit routes. - "I relied on middleware order being exactly my
app.useorder." - Hooks run at named lifecycle points (global → group → route), which is more predictable. Re-express ordering intent as "this is an
onRequestvs this is anonSend," rather than "thisusecomes before that one." - "Do I still need
express.json()?" - No. JSON parsing is built in and gated by your body schema and the body-size limit.
- "What about
app.locals/res.locals?" - Use
app.decorate(...)for app-wide shared resources (typed ontoctx.state) and just set values onctx.statewithin a request for per-request data. - "Is there a code-mod to do this automatically?"
- No, and that is on purpose. The translation is mechanical but the contracts (your schemas and documented responses) are the valuable part, and only you know them. Writing them is the migration.
Migration checklist
- Create the DaloyJS
Appwith a body limit + request timeout. - Add
requestId()+secureHeaders()(replacehelmet). - Map each global Express middleware to a hook or built-in (use the table above).
- Rewrite each
app.METHOD(path, ...)as anapp.route({...})with a uniqueoperationId. - Add
requestschemas for params/query/body, delete manual validation. - Declare every
responsesstatus you actually return. - Replace
next(err)+ error middleware with thrownHttpErrors and an optionalonErrorhook. - Turn routers into
app.group(...)or plugins. - Move static assets to a CDN/proxy; re-implement gated downloads as streaming handlers.
- Replace
app.listen()with the right adapter'sserve(). - Port tests to
app.request(...); add OpenAPI snapshot tests. - Turn on
docs: trueand enjoy the free spec + client SDK.
Where to go next
- Getting started, build a fresh DaloyJS app end to end.
- Routing and Validation, the contract-first core.
- Plugins & encapsulation, the real Router replacement.
- Errors & problem+json, the error-handling model.
- Security, what you get for free instead of a checklist.
- Typed clients, the payoff of going contract-first.
- Why DaloyJS is the best Node.js Express alternative, the case for switching, if you still need to make it.