Skip to content

[Bug]: route/middleware with matcher "/" (bare root) silently never registers #16157

Description

@efragosogit

System information

package.json (relevant deps)

{
  "dependencies": {
    "@medusajs/admin-sdk": "2.17.2",
    "@medusajs/cli": "2.17.2",
    "@medusajs/admin-shared": "2.17.2",
    "@medusajs/cache-redis": "2.17.2",
    "@medusajs/dashboard": "2.17.2",
    "@medusajs/event-bus-redis": "2.17.2",
    "@medusajs/framework": "2.17.2",
    "@medusajs/js-sdk": "2.17.2",
    "@medusajs/medusa": "2.17.2",
    "@medusajs/ui": "4.1.19"
  }
}
  • Node.js version: v24.15.0
  • Database: PostgreSQL (Railway-hosted, version not relevant to this bug — it's pure HTTP routing)
  • OS: macOS 26.0.1 (reproduced via medusa develop; the workaround described below was separately verified working on a Railway/Linux production deployment, so the underlying issue is not OS-specific, though I didn't re-verify the unpatched 404 behavior on Linux specifically)
  • Browser: N/A (server-side routing bug, reproducible via curl)

What happened?

A custom API route file at src/api/route.ts (which should map to the bare root path / under Medusa's file-based routing convention), or a global middleware registered via defineMiddlewares with matcher: "/", is silently never registered with Express. No error is thrown at build time or at server startup — the request to / simply returns a plain 404, identical to a path with no matching route at all.

The same file-based routing convention works correctly for any path with at least one segment (e.g. src/api/ping/route.ts/ping registers and responds correctly).

Root cause

Traced it to packages/core/frameworksrc/http/routes-sorter.ts (compiled: dist/http/routes-sorter.js), in the private method that builds the routing tree used before routes are registered on the Express app:

_RoutesSorter_processRoute = function _RoutesSorter_processRoute(route) {
    const segments = route.matcher.split("/").filter((s) => s.length);
    let parent = this.__routesTree["root"];
    segments.forEach((segment, index) => {
        // ... bucket classification (static/wildcard/regex/params/global) ...
        if (index + 1 === segments.length) {
            parent[bucket].routes.push(route);
            return;
        }
        // ... descend into children ...
    });
};

For a route/middleware with matcher: "/":

"/".split("/")          // => ["", ""]
  .filter(s => s.length) // => []  (both segments are empty strings, filtered out)

segments ends up as an empty array. Since the loop that actually does parent[bucket].routes.push(route) only runs inside segments.forEach(...), and there are zero segments to iterate, the route is never pushed into any bucket of the tree — it silently disappears before RoutesSorter.sort() ever returns it, and therefore it's never passed to app.get() / app.use() in the ApiLoader.

This affects both mechanisms:

  1. A route.ts file placed directly at the root of src/api/ (no subfolder).
  2. A middleware registered via defineMiddlewares({ routes: [{ matcher: "/", middlewares: [...] }] }).

Reproduction

Minimal repro (tested locally with medusa develop, on a project based on the official medusajs/b2b-starter template):

// src/api/route.ts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework";

export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
  res.json({ ok: true });
};
medusa develop
curl -i http://localhost:9000/
# HTTP/1.1 404 Not Found   <-- expected: {"ok":true}

For comparison, the identical file one level deeper works fine:

// src/api/ping/route.ts  (same handler body)
curl -i http://localhost:9000/ping
# HTTP/1.1 200 OK   {"ok":true}

Same silent no-op happens with a middleware-only approach (no route file at all):

// src/api/middlewares.ts
import { defineMiddlewares } from "@medusajs/medusa";

export default defineMiddlewares({
  routes: [
    {
      matcher: "/",
      middlewares: [(req, res, next) => { res.redirect("/app"); }],
    },
  ],
});

GET / still 404s — the middleware handler is never invoked (confirmed with a console.log inside it that never printed).

Expected behavior

A route file at src/api/route.ts, or a defineMiddlewares entry with matcher: "/", should register and handle requests to the bare root path /, the same way any other file-based route or middleware matcher does.

Actual behavior

The route/middleware is silently dropped during the routes-sorting step (see root cause above) and is never registered on the Express app. Requests to / always fall through to Medusa's default 404 handler, with no warning or error logged anywhere (dev or production).

Suggested fix

In _RoutesSorter_processRoute, special-case the zero-segment (bare root) matcher before the forEach, e.g.:

if (segments.length === 0) {
    const bucket = (!route.methods && !route.method) ? "global" : "static";
    this.__routesTree["root"][bucket].routes.push(route);
    return;
}

Workaround (for anyone hitting this in the meantime)

Register a middleware with matcher: "*" instead (which does produce one segment and registers correctly), and guard inside the handler using req.originalUrl === "/" (not req.path, which gets rewritten relative to the "*" mount point and is always "/" for every request once inside that middleware — an easy way to accidentally redirect every route on your API if you check the wrong property):

{
  matcher: "*",
  middlewares: [
    (req, res, next) => {
      if (req.originalUrl === "/") {
        return res.redirect("/app/login");
      }
      next();
    },
  ],
}

Link to reproduction repo

I don't have a minimal public repro repo handy, but the reproduction above is 4 lines of code and reproduces on a completely vanilla create-medusa-app scaffold — happy to put one together if it'd help triage.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions