npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

routedjs

v0.1.7

Published

File-system routing for APIs. Drop a file, get an endpoint.

Readme

routed

File-system routing for APIs. Drop a file, get an endpoint.

Your file tree is your routing table. No manual route registration, no path strings to keep in sync. Routed scans your routes directory, derives URL paths from the file system, and generates a framework-agnostic route manifest. Frameworks are supported via codegen — set your framework and get a native, typed app.

routes/
  health.get.route.ts              → GET  /health
  users/
    index.get.route.ts             → GET  /users
    index.post.route.ts            → POST /users
    $userId.get.route.ts           → GET  /users/:userId
    $userId.put.route.ts           → PUT  /users/:userId
    $userId.delete.route.ts        → DELETE /users/:userId
    $userId/
      visits.get.route.ts          → GET  /users/:userId/visits
      visits/$visitId.get.route.ts → GET  /users/:userId/visits/:visitId

Install

bun add routedjs
# + your framework + your validator
bun add hono    # or koa, express, elysia
bun add zod     # or valibot, arktype, or any Standard Schema validator

Quick start

1. Define a route

// routes/users/$userId.get.route.ts
import { createRoute } from "routedjs";
import { z } from "zod";

export default createRoute({
  schemas: {
    params: z.object({ userId: z.string().uuid() }),
  },
  handler: async ({ params }) => {
    return { id: params.userId, name: "Kyle" };
  },
});

No method. No path. Both are derived from the filename and location.

2. Add config

// routed.config.ts
import { defineConfig } from "routedjs";

export default defineConfig({
  routesDir: "./routes",
  outFile: "./routed.gen.ts",
  framework: "hono",
  dev: {
    command: "bun run server.ts",
  },
});

3. Generate

routed generate

This scans your routes directory and writes routed.gen.ts with both the shared routeTree manifest and a native, fully typed app for your framework:

// routed.gen.ts (auto-generated)
import { defineRouteTree } from "routedjs";
import { Hono } from "hono";
import { routeHandler, wrapMiddleware } from "routedjs/hono";
import routeUsersParamUserIdGet from "./routes/users/$userId.get.route.ts";

export const routeTree = defineRouteTree([
  {
    path: "/users/:userId",
    method: "get",
    route: routeUsersParamUserIdGet,
    middleware: [],
  },
]);

export const app = new Hono()
  .get("/users/:userId", routeHandler(routeUsersParamUserIdGet, "/users/:userId"));

export type AppType = typeof app;

4. Serve

// server.ts
import { app } from "./routed.gen";

export default { fetch: app.fetch, port: 3000 };

That's it. The generated app is a native Hono/Express/Koa/Elysia app — fully typed, ready to use. Change framework in your config to switch frameworks; the route files stay the same.

File conventions

| Pattern | URL | Notes | |---------|-----|-------| | index.get.route.ts | / | index maps to directory root | | users/index.get.route.ts | /users | | | users/$userId.get.route.ts | /users/:userId | $ prefix = dynamic param | | storage/$$path.get.route.ts | /storage/:path* | $$ prefix = catch-all, final segment only | | _admin/users.get.route.ts | /users | _ prefix on dirs = pathless group | | _middleware.ts | — | Directory-scoped middleware |

Filename format: {segment}.{method}.route.ts

Supported methods: get, post, put, patch, delete

Catch-all segments use $$name and must be the final route segment. When you validate params for a catch-all route, model it as string[]. routedjs preserves segment boundaries, so a client param like ["docs/v1", "openapi.json"] round-trips correctly.

Middleware

Per-route

// routes/users/$userId.get.route.ts
import { createRoute } from "routedjs";
import { authMiddleware } from "../_middleware";

export default createRoute({
  middleware: [authMiddleware],
  handler: async ({ params }) => { ... },
});

Directory-scoped

Create a _middleware.ts in any directory. It applies to all routes in that directory and below.

// routes/users/_middleware.ts
import { createMiddleware } from "routedjs";

export default createMiddleware(async ({ ctx, next }) => {
  console.log("runs before all /users/* routes");
  await next();
});

Middleware stacks root-first: root _middleware.ts runs first, then nested directories, then per-route middleware, then the handler.

If a middleware adds typed state, pass that as the first generic to createMiddleware<TProvides>(). If it depends on state from an earlier middleware, pass the required state as the second generic:

import { createMiddleware, createRoute } from "routedjs";

const auth = createMiddleware<{ user: { id: string } }>(async ({ ctx, next }) => {
  ctx.set("user", { id: "123" });
  await next();
});

const subject = createMiddleware<
  { subjectId: string },
  { user: { id: string } }
>(async ({ ctx, next }) => {
  ctx.set("subjectId", ctx.get("user").id);
  await next();
});

export default createRoute({
  middleware: [auth, subject],
  handler: ({ ctx }) => ({
    userId: ctx.get("user").id,
    subjectId: ctx.get("subjectId"),
  }),
});

Middleware order is type-checked, so subject cannot be listed before auth.

Validation

Schemas are optional and work with any Standard Schema validator — Zod, Valibot, ArkType, or anything else that implements the spec. When provided, routedjs validates automatically and returns 400 with structured errors on failure.

import { z } from "zod"; // or valibot, arktype, etc.

export default createRoute({
  schemas: {
    params: z.object({ userId: z.string().uuid() }),
    query: z.object({ limit: z.coerce.number().optional() }),
    body: z.object({ name: z.string(), email: z.string().email() }),
    responses: {
      200: z.object({ id: z.string() }),
      404: z.object({ error: z.string() }),
    },
  },
  handler: async ({ params, query, body }) => {
    // params, query, body are typed and validated
    return { id: params.userId };
  },
});

Route context

Handlers and middleware receive a framework-agnostic ctx:

  • ctx.request: standard Web Request
  • ctx.status(code) and ctx.setHeader(name, value): set status/headers for plain-object returns
  • ctx.json(...), ctx.text(...), ctx.redirect(...): return a Response directly
  • ctx.raw: underlying framework request/response context
export default createRoute({
  handler: async ({ ctx }) => {
    ctx.status(201);
    ctx.setHeader("x-created", "yes");
    return { ok: true };
  },
});

ctx.request works the same way across frameworks, including request-body reads:

export default createRoute({
  handler: async ({ ctx }) => {
    const bodyText = await ctx.request.text();
    return ctx.text(bodyText);
  },
});

If you need full control, return a raw Response. Status, headers, binary bodies, redirects, and streaming responses pass through unchanged.

App context typing

If your app seeds always-present values like db, cache, or logger at the app level, you can register that context once and get typed ctx.get(...) access in handlers and middleware without repeating a bridge middleware on every route.

// app/routed.d.ts
import "routedjs";

declare module "routedjs" {
  interface Register {
    appContext: {
      db: {
        query: (sql: string) => Promise<unknown>;
      };
      cache: {
        get: (key: string) => Promise<string | null>;
      };
    };
  }
}

Then your routes can read that context directly:

import { createRoute } from "routedjs";

export default createRoute({
  handler: async ({ ctx }) => {
    const cached = await ctx.get("cache").get("health");
    if (cached) return { status: cached };

    await ctx.get("db").query("select 1");
    return { status: "ok" };
  },
});

On Hono, routed automatically bridges app-level c.set(...) / c.get(...) values into routed ctx.get(...), and mirrors routed ctx.set(...) back to the underlying Hono context. Other frameworks can provide equivalent runtime state through global or directory middleware.

Frameworks

Set framework in your config and routed generate produces a native, typed app for that framework while still exporting the shared routeTree. Your route files stay the same — only the generated output changes.

Hono

// routed.config.ts
framework: "hono"

// server.ts
import { app } from "./routed.gen";
export default { fetch: app.fetch, port: 3000 };

The generated Hono app preserves Hono client inference for json request bodies, query params, and typed await res.json() responses.

Express

// routed.config.ts
framework: "express"

// server.ts
import { app } from "./routed.gen";
app.listen(3000);

Koa

// routed.config.ts
framework: "koa"

// server.ts
import { app } from "./routed.gen";
app.listen(3000);

Elysia

// routed.config.ts
framework: "elysia"

// server.ts
import { app } from "./routed.gen";
app.listen(3000);

Without framework (generic route tree only)

If you omit framework, the generated file exports just the framework-agnostic routeTree that you wire into any framework at runtime:

import { createHonoApp } from "routedjs/hono";
import { routeTree } from "./routed.gen";
const app = createHonoApp(routeTree);

This still works but produces an untyped app — use framework for full type inference.

Type-safe client

Routed can generate a fully typed API client from your route definitions. Add client to your config:

// routed.config.ts
export default defineConfig({
  routesDir: "./routes",
  outFile: "./routed.gen.ts",
  client: {
    outFile: "./routed.client.ts",
  },
});

Run routed generate and use the client:

import { createApiClient } from "./routed.client";

const api = createApiClient({ baseUrl: "http://localhost:3000" });

// Fully typed — params, query, body, and response
const { data } = await api.users[":userId"].get({
  params: { userId: "abc-123" },
});
// data: { id: string, name: string }

const file = await api.storage[":path*"].get({
  params: { path: ["docs", "api", "openapi.json"] },
});

Types are inferred from your schemas at compile time. At runtime, the client is a thin wrapper around fetch — no runtime code generation, just typed HTTP calls.

Response validation

Adapters can optionally validate handler return values against your responses schemas. Off by default — enable it to catch handler bugs during development. Pass validateResponses when using the runtime API:

import { createHonoApp } from "routedjs/hono";
const app = createHonoApp(routeTree, { validateResponses: true });

When enabled, routedjs validates plain-object returns against the schema matching the buffered response status. Available on all four frameworks.

OpenAPI

Routed generates OpenAPI specs from your route schemas and metadata. It defaults to OpenAPI 3.1.0, and can also emit 3.0.3 when you need a 3.0-compatible consumer:

import { generateOpenAPISpec } from "routedjs/openapi";

const spec = generateOpenAPISpec(routeTree, {
  info: { title: "My API", version: "1.0.0" },
  specVersion: "3.0.3",
});

OpenAPI metadata lives on meta in createRoute, including summary, description, tags, deprecated, and operationId.

import { createRoute } from "routedjs";
import { z } from "zod";

export default createRoute({
  meta: {
    summary: "Get user by ID",
    tags: ["users"],
    operationId: "getUser",
  },
  schemas: {
    params: z.object({ userId: z.string().uuid() }),
    responses: {
      200: z.object({ id: z.string(), name: z.string() }),
      404: z.object({ error: z.string() }),
    },
  },
  handler: async ({ params }) => ({ id: params.userId, name: "Kyle" }),
});

If you're using Zod schemas for OpenAPI generation, install zod-to-json-schema as well:

bun add zod-to-json-schema

Catch-all routes are represented in OpenAPI as a single slash-delimited string path parameter because OpenAPI path params cannot accurately express a segment array.

Or from the CLI — add openapi to your config:

// routed.config.ts
export default defineConfig({
  routesDir: "./routes",
  outFile: "./routed.gen.ts",
  openapi: {
    title: "My API",
    version: "1.0.0",
    specVersion: "3.0.3",
    outFile: "./openapi.json",
  },
});
routed openapi
# → writes openapi.json

Use specVersion: "3.0.3" for generators that expect OpenAPI 3.0 nullable semantics. Leave it unset to emit 3.1.0 with JSON Schema draft 2020-12 output.

CLI

routed generate

One-shot codegen. Scans your routes directory and writes routeTree, plus a typed framework app when framework is configured (and client output, if configured).

routed dev

Watches your source directory, regenerates the manifest when route files change, and restarts your server on any file change. One command, one watcher, no duplication.

routed dev
# → generates routed.gen.ts
# → spawns: bun run server.ts
# → watching for changes...

The server command comes from dev.command in your config.

routed openapi

Generates an OpenAPI spec from your routes. Requires openapi in your config.

bun run examples:generate

Regenerates the checked-in examples/*/routed.gen.ts files. This also runs automatically during prepublishOnly so example output stays in sync with the current generator.

Benchmarks

All four frameworks benchmarked over real HTTP on Apple M2 Max (bun 1.3.10):

Request throughput (avg µs/req, lower is better):

| Scenario | Hono | Elysia | Express | Koa | |----------|------|--------|---------|-----| | Static route | 66 | 44 | 57 | 73 | | + 2 middleware | 52 | 48 | 69 | 59 | | Dynamic param | 48 | 53 | 59 | 61 | | + body validation | 63 | 74 | 92 | 105 |

Run benchmarks locally:

bun run bench

License

MIT