Skip to main content

Command Palette

Search for a command to run...

Migrating from Express to DaloyJS: A Node.js Migration Field Guide

Updated
8 min read
Migrating from Express to DaloyJS: A Node.js Migration Field Guide
D
Devlin Duldulao is the creator of DaloyJS, a software engineer, educator, and published author with 10+ years of experience building and teaching modern web development. Originally from the Philippines and based in Norway since 2019, he writes books and courses for developers who want to ship real, secure systems — and takes his Asian home cooking about as seriously as his TypeScript.

Express has been the default Node.js framework for so long that "Node API" and "Express app" are basically synonyms in most people's heads. I have shipped a lot of Express. I have also inherited a lot of Express, which is a different and more character-building experience. The pattern is always the same: a thin, elegant core, wrapped in a thick layer of middleware that someone added over three years to cover everything the core does not do.

DaloyJS (@daloyjs/core) is the framework I now reach for, and the pitch is simple: it is the modern Express alternative where the stuff you always end up adding (validation, docs, types, security defaults) is already there, generated from a single route definition. This is the migration guide I wish I had, aimed at engineers who already know Express well and want to know exactly what changes.

The core architectural difference

Express is a middleware pipeline. A request walks through an ordered list of (req, res, next) functions, and somewhere in that list a function decides to call res.send(). The Express docs say it plainly: "An Express application is essentially a series of middleware function calls." That is the whole model, and its flexibility is also its weakness, because nothing about the model knows what your route accepts or returns.

DaloyJS is contract-first. A route is a single object that declares its method, path, input schemas, output schemas, and handler. From that one declaration you get runtime validation, end-to-end TypeScript types, an OpenAPI document, and a generated client SDK. The route is not described by a contract somewhere else. The route is the contract.

The practical translation table:

Express DaloyJS
app.get(path, handler) app.route({ method: "GET", path, ... })
handler(req, res) calls res.send() handler(ctx) returns { status, body }
req.params / req.query / req.body ctx.params / ctx.query / ctx.body (validated)
res.set(name, value) ctx.set.headers.set(name, value)
middleware (req, res, next) hooks: onRequest, beforeHandle, afterHandle, onError, onSend, onResponse
express.Router() app.group(prefix) and app.register(plugin)
error middleware (err, req, res, next) throw typed errors + onError hook
app.listen(port) serve(app, { port }) from @daloyjs/core/node

Step 1: Bootstrap and the return-value model

// Express
const express = require("express");
const app = express();
app.use(express.json());
app.get("/health", (req, res) => res.json({ ok: true }));
app.listen(3000);
// DaloyJS
import { App } from "@daloyjs/core";
import { serve } from "@daloyjs/core/node";
import { z } from "zod";

const app = new App({
  bodyLimitBytes: 64 * 1024,
  requestTimeoutMs: 5_000,
  openapi: { info: { title: "My API", version: "1.0.0" } },
  docs: true,
});

app.route({
  method: "GET",
  path: "/health",
  operationId: "healthCheck",
  responses: {
    200: { description: "Healthy", body: z.object({ ok: z.boolean() }) },
  },
  handler: async () => ({ status: 200, body: { ok: true } }),
});

const { port } = serve(app, { port: 3000 });

The thing to internalize: handlers return their response. No res object, no risk of forgetting to end the response, no accidentally calling res.send() twice. The handler is a pure-ish function from context to a result, which is exactly why it is so easy to unit test.

Also notice bodyLimitBytes and requestTimeoutMs are first-class config, not middleware you hope someone remembered to add. Body parsing is built in, so express.json() has no equivalent because you do not need one.

Step 2: Validation is not optional anymore (in a good way)

In Express, req.body is any. You either trust it (do not) or wire up a validator manually on every route. In DaloyJS, schemas live in the route definition and run automatically:

app.route({
  method: "POST",
  path: "/books",
  operationId: "createBook",
  request: {
    body: z.object({ title: z.string().min(1), author: z.string() }),
  },
  responses: {
    201: { description: "Created", body: z.object({ id: z.string() }) },
    400: { description: "Validation failed" },
  },
  handler: async ({ body }) => {
    // body is fully typed and already validated
    const id = await saveBook(body);
    return { status: 201, body: { id } };
  },
});

DaloyJS speaks Standard Schema, so you are not married to one validation library. Zod, Valibot, ArkType, TypeBox all work. Pick your poison. The point is the schema is declared once and powers validation, types, and docs simultaneously.

Step 3: Middleware to hooks

This is the migration step that takes the most thought, because Express middleware is a single linear concept and DaloyJS splits it into a lifecycle. The hooks, in execution order:

  • onRequest: earliest, before body parsing. Good for logging, request IDs.

  • beforeHandle: right before the handler. Good for auth and short-circuiting.

  • afterHandle: after the handler returns.

  • onError: when anything throws.

  • onSend / onResponse: as the response is serialized and sent.

A global logging middleware:

// Express: app.use((req, res, next) => { ...; next(); })
app.use({
  onRequest(req) {
    console.log(req.method, new URL(req.url).pathname);
  },
});

An auth guard. In Express a guard calls next() or ends the response. In DaloyJS, beforeHandle either returns a Response to short-circuit, returns nothing to continue, or (my preference) throws a typed error:

import { UnauthorizedError } from "@daloyjs/core";

app.route({
  method: "GET",
  path: "/admin",
  operationId: "admin",
  hooks: {
    beforeHandle(ctx) {
      if (!ctx.request.headers.get("x-auth")) {
        throw new UnauthorizedError("no auth");
      }
    },
  },
  responses: { 200: { description: "ok" }, 401: { description: "denied" } },
  handler: async () => ({ status: 200, body: "secret" }),
});

For production auth you would use the shipped hooks instead of hand-rolling: bearerAuth({ validate }), basicAuth(), JWT/JWK verifiers, and session helpers. They plug straight into hooks.

The middleware mapping cheat sheet

This is the part you actually came for. Your Express app.use(...) stack maps roughly like this:

Express middleware DaloyJS replacement
express.json() / body-parser built in (with body-size cap)
helmet secureHeaders (first-party, on by default in templates)
cors first-party CORS config, not *-with-credentials by default
morgan structured logging + requestId
express-rate-limit rateLimit (first-party)
cookie-parser readRequestCookie / serializeCookie
custom auth middleware bearerAuth / basicAuth / JWT hooks
express.static path-safe static serving + assertSafeRelativePath

A good chunk of your middleware stack just disappears because it was compensating for things Express core left out, and DaloyJS puts in core.

Step 4: Error handling

Express error handling is a four-arg middleware that must be registered last, and forgetting the order silently breaks it. DaloyJS uses thrown errors plus a standard rendering:

import { NotFoundError, ConflictError } from "@daloyjs/core";

handler: async ({ params, body }) => {
  const existing = await findBook(params.id);
  if (!existing) throw new NotFoundError(`No book ${params.id}`);
  if (await titleTaken(body.title)) throw new ConflictError("title taken");
  return { status: 200, body: await update(params.id, body) };
};

Errors render as RFC 9457 problem+json, and in production 5xx responses are redacted by default so you do not leak internals. If you want custom handling, the onError hook gives you a place to map domain errors to responses centrally, which is much nicer than scattering try/catch across handlers.

Step 5: Routers to groups and plugins

express.Router() has two replacements depending on intent:

  • app.group(prefix) for path-prefixed route collections that share hooks.

  • app.register(plugin) for encapsulated, reusable feature modules (think Fastify plugins).

const books = app.group("/books");
books.use({ onRequest: () => console.log("book route hit") });
books.route({ method: "GET", path: "/:id", operationId: "getBook", /* ... */ });

Step 6: Testing

Drop supertest. DaloyJS apps accept a web-standard Request directly:

const res = await app.request(
  new Request("http://test/books/123"),
);
assert.equal(res.status, 200);
const body = await res.json();

No port binding, no teardown, fast. Because it is the real Request/Response pair, your tests exercise the same code path production does.

The honest migration strategy

Do not big-bang rewrite a working Express app. The realistic path:

  1. Stand up DaloyJS for new endpoints alongside the existing Express app (strangler-fig style, behind the same reverse proxy).

  2. Move the highest-value or highest-risk routes first, the ones where typed contracts and security defaults pay off most.

  3. Let the generated OpenAPI and client SDK justify the move to whoever signs off on your sprint.

Express is not bad. It is just from an era where "minimal core, bring your own everything" was the right call. The cost of that philosophy is the middleware archaeology you do every time you join a new team. DaloyJS makes a different bet: the contract and the security perimeter are the framework's job, not yours. After a decade of Express archaeology, that is a bet I am happy to take.

If you have not decided whether the move is worth it yet, I made the broader case in a companion post: why DaloyJS is the best Express alternative for Node.js. This guide is the how; that one is the why.