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/framework → src/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:
- A
route.ts file placed directly at the root of src/api/ (no subfolder).
- 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.
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" } }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)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 viadefineMiddlewareswithmatcher: "/", 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→/pingregisters and responds correctly).Root cause
Traced it to
packages/core/framework→src/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:For a route/middleware with
matcher: "/":segmentsends up as an empty array. Since the loop that actually doesparent[bucket].routes.push(route)only runs insidesegments.forEach(...), and there are zero segments to iterate, the route is never pushed into any bucket of the tree — it silently disappears beforeRoutesSorter.sort()ever returns it, and therefore it's never passed toapp.get()/app.use()in theApiLoader.This affects both mechanisms:
route.tsfile placed directly at the root ofsrc/api/(no subfolder).defineMiddlewares({ routes: [{ matcher: "/", middlewares: [...] }] }).Reproduction
Minimal repro (tested locally with
medusa develop, on a project based on the officialmedusajs/b2b-startertemplate):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):
GET /still 404s — the middleware handler is never invoked (confirmed with aconsole.loginside it that never printed).Expected behavior
A route file at
src/api/route.ts, or adefineMiddlewaresentry withmatcher: "/", 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 theforEach, e.g.: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 usingreq.originalUrl === "/"(notreq.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):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-appscaffold — happy to put one together if it'd help triage.