SecurityVibe codingExpress alternative

DaloyJS in 2027: The TypeScript REST API Framework Built for the Vibe-Coding Apocalypse and Alternative to Express?

Vibe-coded apps are getting breached because nobody reads the code anymore. Here is the blunt case for DaloyJS, a secure-by-default, runtime-agnostic TypeScript REST framework, why it is the Express alternative I now reach for, and the migration guide that actually gets you there.

Devlin DuldulaoFullstack cloud engineer17 min read

I have been writing backends for about ten years now. I started in Manila, I now live in Norway, and somewhere along that journey I learned a hard truth: most production incidents are not caused by clever attackers. They are caused by us shipping code that we never really read. In 2027 that problem has a name, and the name is vibe coding.

Let me explain what I mean, because "vibe coding" gets thrown around as a joke and it is not a joke anymore.

The state of the world in 2027

Most backend code today is written by an AI. I am not being dramatic. Non-developers describe an app in plain English, the model produces something that runs, and it gets deployed within the hour. Engineers are doing the same thing, just with more confidence and slightly better commit messages. We let agents install dependencies, run the tests, and open the pull request. The code works on the happy path, the demo looks great, everyone claps.

Then it goes to production with no body limits, input validation that is "optional" because it slowed down the demo, an admin route that somebody forgot to unmount, and an outbound fetch that will cheerfully call http://169.254.169.254/latest/meta-data/ if you ask it nicely. The app is one crafted request away from leaking its own cloud credentials, and nobody on the team can tell you that, because nobody on the team wrote that line.

I want to be fair here. The AI is not the villain. The AI did exactly what we asked. We asked it to "make it work," and there is a famous line from the Supabase and Aikido write-up on secure-by-default development that I keep pinned to my monitor: "If you tell an AI to make something work, it might remove the very security checks that protect you." That is the whole problem in one sentence. The model optimizes for the request, and "make it work" almost never includes "and also do not let strangers read my database."

On top of that, the dependency tree itself became the attack surface. In the last couple of years we have watched self-replicating npm worms, malicious postinstall scripts, CI cache poisoning, and a newer favorite called slopsquatting, where an attacker pre-registers a package name that an AI assistant is statistically likely to hallucinate. Your agent confidently runs pnpm add @types/fastify-helmet, the package exists because somebody was waiting for exactly that hallucination, and now you have a wallet drainer in your lockfile. Fun.

So that is the environment. Fast code, written by machines, deployed by people who trust the machine, on top of a registry that has become a minefield. This is the world DaloyJS was built for. Not the world of five years ago. This one.

What DaloyJS actually is

DaloyJS (@daloyjs/core) is a runtime-portable, contract-first TypeScript web framework. That is a mouthful, so here is the plain version. You define a route once. That single definition is the source of truth for validation, your TypeScript types, your OpenAPI 3.1 document, your typed client, and your contract tests. No decorators, no separate schema files that drift out of sync, no "the docs say one thing and the code does another" energy.

And it runs the same code on Node, Bun, Deno, Cloudflare Workers, Vercel (Node and Edge), Fastly Compute, and AWS Lambda. The core only ever sees a web-standard Request and returns a Response. The runtime-specific stuff lives in thin adapters at the edge.

But the part I actually care about, the part that made me stop using other frameworks for new projects, is the design stance. DaloyJS attacks the vibe-coding problem from two directions at the same time.

  1. A secure-by-default runtime. The dangerous things are off until you turn them on. The safe things are on until you turn them off. The framework will literally refuse to boot on certain unsafe configurations.
  2. A hardened supply chain. Zero runtime dependencies, npm provenance, SBOMs, and a pnpm posture that assumes the registry is hostile, because it is.

The trick, and the reason I am writing a whole blog post about it instead of one tweet, is that none of this costs you developer experience. The secure path is also the path of least resistance. You do not earn security by suffering. You get it by default and then you have to go out of your way to remove it. That is the inversion that matters.

Let me show you, because I trust code more than I trust adjectives.

Hello world, and notice what you did not have to do

ts
import { z } from "zod";
import {
  App,
  NotFoundError,
  secureHeaders,
  rateLimit,
  requestId,
} from "@daloyjs/core";
import { serve } from "@daloyjs/core/node";

const app = new App({ bodyLimitBytes: 1024 * 1024, requestTimeoutMs: 5_000 });

// First-party security middleware. In other frameworks this is three plugins,
// three READMEs, and one Stack Overflow tab you never close.
app.use(requestId());
app.use(secureHeaders());
app.use(rateLimit({ windowMs: 60_000, max: 120 }));

app.route({
  method: "GET",
  path: "/books/:id",
  operationId: "getBookById",
  tags: ["Books"],
  request: { params: z.object({ id: z.string() }) },
  responses: {
    200: {
      description: "Found",
      body: z.object({ id: z.string(), title: z.string() }),
    },
    404: { description: "Not found" },
  },
  handler: async ({ params }) => ({
    status: 200,
    body: { id: params.id, title: `Book ${params.id}` },
  }),
});

serve(app, { port: 3000 });

That is a complete API. Here is what you got for free without typing a single extra line: a hard body-size cap so a 4GB upload cannot eat your memory, a request timeout so a slow-loris client cannot hold your handlers hostage, a JSON parser that strips __proto__ and constructor so prototype pollution does not happen, a router that rejects .. path segments before it walks anything, header sanitizers that reject CRLF and NUL so nobody can smuggle a response split through your logs, and RFC 9457 problem+json errors that redact their detail field in production so your 500 does not hand a stranger a stack trace.

The validator is Zod 4 here, but DaloyJS speaks Standard Schema, so Valibot, ArkType, and TypeBox all work too. No lock-in. If your team already has opinions about schema libraries, bring them.

Now compare that to a typical vibe-coded Express app. The model gives you app.get('/books/:id', ...), it works, it ships. There is no body limit. There is no timeout. The JSON parser will happily accept {"__proto__": {"isAdmin": true}}. The error handler prints the stack trace because that was helpful during development and nobody removed it. None of that is malicious. It is just absence. DaloyJS is built on the idea that absence is the bug.

The framework that refuses to start

This is my favorite feature and it sounds aggressive when you first hear it. DaloyJS will refuse to boot if you configure it in a way that is known to be dangerous. It does not warn. It does not log politely and continue. It throws and your process does not come up.

Things that will stop your app from starting:

  • cors({ origin: "*" }) with credentials: true. This is the classic "I just wanted the CORS error to go away" move, and it is also how you let any website on the internet make authenticated requests as your logged-in user. DaloyJS throws at construction.
  • A weak session secret. If your secret is shorter than 32 bytes or is one of the known weak strings (think "secret", "changeme"), it will not boot.
  • A session() plus a state-changing route with no csrf() protection. The framework notices you have cookies and a POST route and no CSRF, and it stops you.
  • Unconfigured X-Forwarded-* handling in production. If you are behind a proxy and you have not told the framework whom to trust, it will not silently believe spoofed client IPs.
ts
// This throws at construction. Your CI catches it. Not your incident channel.
const app = new App({
  cors: { origin: "*", credentials: true },
});

I know some of you just twitched. "What if I have a legitimate reason?" Then you fix the default for everyone with a scoped knob, you do not strip the guardrail inline. The framework's philosophy, which is written into its contributor rules in capital letters, is that bad defaults are bugs. If a guard genuinely blocks a real use case, the answer is a narrower override, not a deleted check. For service-to-service deployments behind a mesh there is even a preset: "internal-service" that turns off the browser-only guards (the CORS guard, the CSRF boot guard, auto secure headers) while keeping every input, parser, credential, and SSRF guard on. The choice gets logged at boot, and you can audit the live posture with app.getSecurityPosture(). Opt-in, visible, reversible. That is the pattern.

This matters specifically for vibe coding because the failure mode of an AI is not malice, it is plausibility. The model writes config that looks correct. A refuse-to-boot guard converts "looks correct" into "actually correct or the process dies," and a dead process in CI is a million times cheaper than a live process in production.

SSRF, or the call your handler should never make

Here is the one that keeps cloud engineers up at night. Your app takes a URL from a user, maybe for a webhook, an image import, a link preview. The handler does await fetch(url). An attacker passes http://169.254.169.254/latest/meta-data/iam/security-credentials/, your server fetches it, and now the response body contains your instance's IAM credentials. This is Server-Side Request Forgery and it is responsible for some of the biggest cloud breaches we have on record.

DaloyJS ships fetchGuard(), which is a drop-in replacement for fetch with a hard-deny floor.

ts
import { fetchGuard } from "@daloyjs/core";

const safeFetch = fetchGuard();

app.route({
  method: "POST",
  path: "/import",
  operationId: "import",
  request: { json: z.object({ url: z.string().url() }) },
  responses: { 200: { description: "ok" } },
  handler: async ({ request }) => {
    const { url } = await request.json();
    const upstream = await safeFetch(url); // refuses 169.254.169.254
    return { status: 200 as const, body: await upstream.text() };
  },
});

The deny list is not advisory. It covers every documented cloud metadata IP: the AWS, Azure, DigitalOcean, and GCP IMDS address 169.254.169.254, the AWS ECS task metadata and EKS Pod Identity ranges, the Oracle 192.0.0.192, the Alibaba 100.100.100.200, link-local, loopback, and private ranges. And here is the detail I really like: it re-resolves on redirects, so an attacker cannot hand you a friendly https://example.com that quietly 302s to http://169.254.169.254. The hard-deny floor cannot be lifted by any allow flag. Even if you misconfigure your allow list, you cannot accidentally re-expose the metadata endpoint. That is what "secure by default" means in practice: the safe thing is not something you remembered to do, it is something you would have to fight the framework to undo.

Tokens, because everybody gets JWT wrong

JWT is a minefield and most tutorials walk you straight into it. The alg: "none" attack, algorithm confusion where an attacker swaps RS256 for HS256 and signs with your public key, tokens with no expiry that live forever. DaloyJS bakes the lessons in.

ts
import { jwk } from "@daloyjs/core/jwk";
import { requireScopes } from "@daloyjs/core";

// Verify tokens against your identity provider's JWKS.
const auth = jwk({
  jwksUri: "https://your-tenant.auth-provider.com/.well-known/jwks.json",
  issuer: "https://your-tenant.auth-provider.com/",
  audience: "https://api.yourapp.com",
});

app.route({
  method: "DELETE",
  path: "/projects/:id",
  operationId: "deleteProject",
  auth: { scheme: "bearer" },
  hooks: [auth, requireScopes(["projects:write"])],
  request: { params: z.object({ id: z.string() }) },
  responses: {
    204: { description: "Deleted" },
    401: { description: "Unauthorized" },
    403: { description: "Forbidden" },
  },
  handler: async ({ params }) => {
    // ... by the time you are here, the token is verified and scoped
    return { status: 204 as const };
  },
});

The signer and verifier refuse alg: "none". They accept only an explicit algorithm allowlist, never "whatever the token claims." They refuse to mix HS secrets with JWK key material, which is the algorithm-confusion defense. They refuse to sign a token without an exp. And they refuse HS-shaped secrets under 32 bytes per RFC 7518. The jwk() middleware is asymmetric-only on purpose, requires https:// JWKS URLs, caches them with in-flight promise dedup, and cross-checks the kid and the JWT-versus-JWK algorithm. You do not have to know any of this to be protected by it, which is the point. The default is the secure one.

DaloyJS is a resource server, by the way, not an identity provider. It verifies and enforces tokens. It does not run a login page. Bring Keycloak, Zitadel, Auth0, Entra ID, Cognito, whatever you like. Do not build your own authorization server. I have seen people try. It does not end well.

Now the developer experience, which is why you will actually use it

I have made the security pitch. But here is the thing about security tools: if they are miserable to use, people route around them, and a guardrail you disabled is worse than no guardrail because it gives you false confidence. So the DX has to be genuinely good, not "good for a security framework." Let me show you the part that made me a believer.

One route definition gives you a typed client with zero codegen. Look:

ts
import { createClient } from "@daloyjs/core/client";

const client = createClient(app, { baseUrl: "http://localhost:3000" });

const r = await client.getBookById({ params: { id: "1" } });
//    ^? { status: 200; body: { id: string; title: string } } | { status: 404; ... }

That getBookByIdmethod, its input shape, and its per-status response union are all inferred from the route definition. If you change the route, the client type changes, and your consuming code stops compiling until you fix it. No generation step, no stale client, no "the mobile team is still on the old contract" meeting. For consumers that cannot import your TypeScript (a different repo, a different language), one pnpm gen command runs your live OpenAPI spec through Hey API and emits a fully typed fetch SDK.

And the docs. This is the FastAPI feature everyone wishes Node had, and it is one line:

ts
const app = new App({
  openapi: { info: { title: "My API", version: "1.0.0" } },
  docs: true, // mounts GET /docs (Scalar), GET /openapi.json, GET /openapi.yaml
});

That mounts an interactive Scalar UI at /docs, plus the JSON and YAML specs, with a strict CSP. Switch to Swagger UI or Redoc with one word (ui: "swagger"). The docs are always contract-accurate because they are generated from the same route definitions that run your server. They cannot go stale. If you omit the info block, DaloyJS reads your package.json for the title and version. Less boilerplate, fewer things to forget.

Here is a fuller route, the kind I actually write, with auth, validation, and typed error responses:

ts
import { z } from "zod";
import { App, NotFoundError, bearerAuth, secureHeaders, requestId } from "@daloyjs/core";

const BookSchema = z.object({ id: z.string(), title: z.string() });

const app = new App({ docs: true, bodyLimitBytes: 64 * 1024 })
  .use(requestId())
  .use(secureHeaders())
  .route({
    method: "POST",
    path: "/books",
    operationId: "createBook",
    tags: ["Books"],
    auth: { scheme: "bearer" },
    hooks: bearerAuth({ validate: (t) => t === process.env.API_TOKEN }),
    request: { body: BookSchema },
    responses: {
      201: { description: "Created", body: BookSchema },
      401: { description: "Unauthorized" },
      422: { description: "Validation error" },
    },
    handler: async ({ body }) => {
      // body is already validated and typed as { id: string; title: string }.
      return { status: 201 as const, body };
    },
  });

The bearerAuth comparison uses a timing-safe equal under the hood, so you do not leak token bytes through response timing. The body is validated against the schema before your handler runs, and if it fails, the client gets a clean 422 problem+json, not a 500 with a stack trace. You declared 422 in the contract, so it is in your OpenAPI doc and your typed client too. One source of truth, the whole way down.

The supply chain, because your dependencies are also your attack surface

You can write the most careful handler in the world and still get owned through a postinstall script in a transitive dependency you never chose. So the second front matters as much as the first.

@daloyjs/corehas zero runtime dependencies. Not "few." Zero. That is enforced in CI by a gate called verify:no-runtime-deps, and it is treated as a floor, not a goal. A hallucinated dependency literally cannot transitively land in the published tarball, because there is no dependency tree to hide in. Every feature I described in this article, the JWT verifier, the SSRF guard, the WAF, the rate limiter, the WebSocket layer, is built on web-standard APIs and Node built-ins. No node_modules surprise party.

The scaffolder ships a hardened .npmrc:

ini
ignore-scripts=true
minimum-release-age=1440
strict-peer-dependencies=true
prefer-frozen-lockfile=true
verify-store-integrity=true
provenance=true

ignore-scripts=true kills lifecycle-script payloads, which is how most install-time worms detonate. minimum-release-age=1440 refuses to install anything published in the last 24 hours, which is the typical window in which a malicious package gets detected and unpublished. Combined, those two defaults blunt slopsquatting hard. The attacker pre-registers the name your agent will hallucinate, but your install refuses fresh packages and refuses lifecycle scripts, and a workspace gate called verify:known-dep-names refuses any top-level dependency name that is not on an explicit allowlist. So pnpm add some-hallucinated-name cannot quietly land in a package.json. It forces a one-line diff that a human has to look at. That review checkpoint is the whole defense, and the framework makes it unavoidable.

On top of that, @daloyjs/core is published with npm provenance and ships CycloneDX 1.5 plus SPDX 2.3 SBOMs, regenerated and verified on every release. There is a whole pile of CI gates with names like verify:no-registry-exfiltration, verify:no-remote-exec, verify:no-lifecycle-scripts, and verify:no-weak-random, and they carry IOC coverage for active campaigns. There are even gates that scan AI-agent config files (CLAUDE.md, .cursorrules, AGENTS.md) for the prompt-injection persistence trick the TrapDoor crypto-stealer used, and gates that refuse editor configs that auto-run a command on folder open, which is how the Miasma worm detonated. The supply chain is treated as hostile, which in 2027 is just accurate.

Defense in depth, when the edge WAF is somebody else's budget line

Not every team has a CDN WAF in front of their app. DaloyJS gives you first-party, opt-in layers so the framework itself can do some of that work:

  • waf() runs a scored inbound inspection pass for SQLi, XSS, NoSQL operator injection, and command injection. It is defense in depth, not a ModSecurity replacement, and it has a log mode so you can tune against real traffic before you start blocking.
  • autoBan() is fail2ban for your app: repeat offenders who keep hitting 401/403/429 get temporarily banned, with exponential escalation and decay.
  • botGuard() verifies declared crawlers with reverse-DNS plus forward-confirm, so a request claiming to be Googlebot has to actually be Googlebot.
  • geoBlock(), ipReputation(), concurrencyLimit(), and requestDecompression() (a zip-bomb guard that aborts during inflation) round it out.
ts
import { waf } from "@daloyjs/core/waf";
import { autoBan } from "@daloyjs/core/auto-ban";

app.use(waf({ mode: "block", blockThreshold: 5 }));
app.use(autoBan({ trustProxyHeaders: false, banMs: 60_000 }));

And on WebSockets, the framework closes the Cross-Site WebSocket Hijacking class of bug by refusing to register a production WS route unless you provide an Origin policy or explicitly acknowledge a cross-origin upgrade. Cookie auth alone does not stop a malicious site from opening an authenticated handshake from your user's browser, which is exactly the Storybook CVE-2026-27148 pattern. The Origin check runs before your upgrade hook, on both Node and Bun. Again: the safe thing is the default, and you have to go out of your way to remove it.

Same code, every runtime

Because the core is just Request to Response, deployment is a one-line import swap. No rewrite when your platform decision changes, and platform decisions always change.

ts
import { serve } from "@daloyjs/core/node";        // Node: Railway, Render, Fly, Heroku
import { serve } from "@daloyjs/core/bun";          // Bun
import { serve } from "@daloyjs/core/deno";         // Deno
import { toFetchHandler } from "@daloyjs/core/cloudflare"; // Cloudflare Workers
import { toFetchHandler } from "@daloyjs/core/vercel";     // Vercel Node / Edge
import { toLambdaHandler } from "@daloyjs/core/lambda";    // AWS Lambda

The routing is fast too, if you care about that sort of thing (I do). Static routes resolve through a single Map.get at roughly 12 million ops per second, dynamic routes walk a trie in time proportional to the number of path segments, body parsing is lazy and only runs when a route declares a body schema, and there is no regex on the hot path. The secure defaults do not cost you throughput.

So is this the Express alternative? Yes, and here is the honest version

The title of this post ends with a question mark on purpose. "Alternative to Express" is a big claim, and Express is not bad software. It powered half the internet for a decade, it is in my muscle memory, and I have shipped a lot of money-making code on top of it. I am not here to dunk on it. I am here to argue that the thing that made Express great in 2015, that it does almost nothing and gets out of your way, is exactly the thing that makes it dangerous in the vibe-coding era of 2027.

Think about what an Express app actually is. In its own documentation it is described as "essentially a series of middleware function calls." You wire up (req, res, next) callbacks, you mutate res, and you call res.send()to end the cycle. That is a beautiful, minimal model. It is also a blank canvas, and a blank canvas is the worst possible thing to hand an AI that was told to "make it work." The model will not add helmet. It will not add a body limit. It will not add a rate limiter or a request timeout. It will not validate req.body, which is typed any, so TypeScript will not save you either. Every one of those is something a human has to remember to bolt on, and the entire premise of vibe coding is that nobody is remembering anything.

So here is the concrete case for DaloyJS as the Express alternative, point by point, and none of these are things I made up for a blog post. They are the actual reasons written into our migration guide:

  • OpenAPI and a typed client for free. In Express you bolt on swagger-jsdoc, hand-write JSDoc comments above each route, and pray they stay in sync with the code. They never do. In DaloyJS the route definition is the spec, so it cannot drift, and a typed SDK falls out of pnpm gen.
  • Validation the type system actually trusts. Express hands you req.body as anyand wishes you luck. DaloyJS validates with Standard Schema (Zod, Valibot, ArkType) and infers your handler's params, query, and body types from the same schemas that do the runtime checking. One source of truth, no casts.
  • Secure defaults instead of a TODO list. This is the whole thesis of the article. Express ships almost nothing on the security front. DaloyJS ships body limits, request timeouts, header sanitization, prototype-pollution-safe JSON, and one-line secureHeaders() and rateLimit() helpers, and it refuses to boot on configurations that are known to be unsafe.
  • Run the same app everywhere.Express is welded to Node's http module. DaloyJS is built on web-standard Request and Response, so the same app object runs on Node, Bun, Deno, Workers, Vercel, and Lambda. When your platform decision changes (it will), you swap an import, not a framework.
  • Zero runtime dependencies. A fresh Express app pulls in dozens of transitive packages, and every one of them is a slopsquatting target and a postinstall risk. @daloyjs/core has none. There is no dependency tree for a malicious package to hide in.

Now, I am going to be honest about when you should not do this, because a blog post that only tells you to migrate is a sales brochure, not advice. If your app is mostly server-rendered HTML through a view engine like EJS or Pug, DaloyJS is API-first and will fight you. If you depend on one weird Express middleware with no equivalent and no appetite to port it, check that first. And if the app is in stable maintenance-only mode, migration has a cost and you should spend that energy somewhere with upside. Greenfield services, anything where you were about to add OpenAPI anyway, and apps that keep getting bitten by untyped req.body bugs are the sweet spot.

We actually wrote the migration guide, so you do not have to guess

Here is the part I am genuinely proud of. A lot of frameworks tell you they are "a great Express alternative" and then leave you to figure out the move on your own. We wrote the whole thing down. There is a complete, no-prior-knowledge Migrate from Express.js to DaloyJS guide in the docs that maps every Express concept you already know to its DaloyJS equivalent, with before-and-after code for each one.

The core mental shift is one sentence: you stop mutating res and calling next(), and you start declaring a contract and returning a value. Here is the side-by-side that the guide opens with:

text
EXPRESS                              DALOYJS
-------                              -------
app.get(path, (req,res,next) => {    app.route({
  // read from req                     method, path, operationId,
  // mutate res                        request:  { params, query, body },  // schemas
  // res.send() / res.json()           responses:{ 200: { body }, 404: {...} },
  // or next(err)                      handler: async (ctx) => {
})                                       // ctx.params/query/body validated + typed
                                         return { status: 200, body };   // you RETURN
                                       },
                                     })

middleware chain (req,res,next)       hooks (onRequest, beforeHandle,
                                       afterHandle, onError, onSend, onResponse)
express.Router() mini-app             app.group(prefix, opts, fn) / plugins
error mw (err,req,res,next)           throw new NotFoundError(...) + onError hook
app.listen(3000)                      serve(app, { port: 3000 })  // from an adapter

The thing I want you to notice is how much code disappears in the translation. Take a typical Express route with manual validation and manual error handling:

ts
// Express
app.use(express.json()); // required, or req.body is undefined

app.post("/books", requireToken, (req, res) => {
  const { id, title } = req.body;
  if (!id || !title) return res.status(400).json({ error: "id and title required" });
  const book = { id, title };
  books.set(id, book);
  res.status(201).json(book);
});

// and somewhere, the one error middleware you must not forget
app.use((err, req, res, next) => {
  res.status(500).json({ error: "internal" });
});

The DaloyJS version of that route deletes the body-parser line, the manual if (!id || !title) check, the hand-rolled auth status, and the catch-all error middleware, and gives you an OpenAPI entry and a typed client in exchange:

ts
// DaloyJS
import { z } from "zod";
import { App, bearerAuth, NotFoundError } from "@daloyjs/core";

const Book = z.object({ id: z.string(), title: z.string().min(1) });

app.route({
  method: "POST",
  path: "/books",
  operationId: "createBook",
  auth: { scheme: "bearer" },
  hooks: bearerAuth({ validate: (t) => t === process.env.API_TOKEN }),
  request: { body: Book }, // validation replaces the manual if-check
  responses: {
    201: { description: "Created", body: Book },
    401: { description: "Unauthorized" },
    422: { description: "Validation error" }, // returned for you on bad input
  },
  handler: async ({ body }) => {
    books.set(body.id, body);
    return { status: 201, body };
  },
});

You throw a NotFoundError instead of building one by hand. You declare the 422 and DaloyJS returns it for you when validation fails, so your handler only ever runs on valid input. Your helmet becomes secureHeaders(), express-rate-limit becomes rateLimit(), csurf becomes csrf(), cookie-parser becomes readRequestCookie() / serializeCookie(), and express.Router() becomes either app.group() or a proper encapsulated plugin. The migration guide has the full mapping table for all of them, so you are not guessing which DaloyJS thing replaces which Express package.

And you do not have to do it in one heroic weekend. The guide documents a strangler-fig approach: stand DaloyJS up next to your existing Express app, put a reverse proxy in front, and move one resource at a time (/books first, then the next), pointing the proxy at the new routes as they land and deleting the old ones. Both apps can share the same database, the same session store, and the same JWT secrets during the transition, so logins keep working no matter which app serves a given request. You lock each move with contract tests using the in-process app.request() client (no port, no second terminal), and you repeat until Express is empty and you can delete it. That is how you migrate a real production app without a scary big-bang cut-over.

If you want the persuasion version rather than the how-to version, there is also a companion post, Why DaloyJS is the best Node.js Express alternative, that makes the case in more detail. But honestly, the migration guide is the better read, because it shows you the actual code instead of just telling you it is nicer.

Starting a project

bash
pnpm create daloy@latest my-api

# add GitHub Actions, CODEOWNERS, Dependabot, and a SECURITY.md for a company repo
pnpm create daloy@latest my-api --with-ci --code-owner @acme/security

You get a working project with the secure middleware stack already wired, docs: true on, a runtime template of your choice, and containers that ship with a non-root user, tini as PID 1, a HEALTHCHECK, and STOPSIGNAL SIGTERM. The --with-ci bundle even signs your pushed images with Sigstore Cosign and attaches an SBOM attestation, so your consumers can verify the image instead of trusting the registry. You did not have to remember any of that. That is the recurring theme, in case I have been too subtle: you did not have to remember.

Why I think this is the right bet for 2027

I am not going to pretend DaloyJS is magic. It is in public preview, it is a 0.x, and the API can still move between minor versions. It will not write your business logic and it will not stop you from shipping a bug. No framework can save a determined developer from themselves, and I say that as a determined developer who has needed saving.

But here is the argument. The way we build software changed. The volume of code went up, the amount of code any human actually reads went down, and the registry turned into a hunting ground. In that world, the framework cannot be a neutral tool that does whatever you ask. It has to have opinions, it has to default to safe, and it has to refuse to do the obviously dangerous thing even when you (or your AI) confidently ask for it. The security has to be the thing you would have to remove on purpose, not the thing you would have to add on purpose. Because the one thing we have learned about vibe-coded apps is that nobody adds the boring stuff. They just ship.

DaloyJS makes the boring stuff the default and the dangerous stuff the exception, and it does that without making you miserable. One route definition, full types, live docs, a typed client, a hardened runtime, and a supply chain that assumes the worst. If you are starting a new TypeScript API in 2027, that is the trade I would take every single time.

Go read the security docs, run pnpm create daloy@latest, and try to make it boot with a wildcard-credentials CORS config. It will tell you no. That "no" is the whole product.

About the author: Filipino developer in Norway, about ten years of shipping backends, and still convinced that the most dangerous line of code is the one nobody read before deploying it.