Driver FixRecommendedSound, Wi-Fi or graphics acting up? Check drivers firstFind missing or outdated drivers fast.Check DriversOctober DealsAmazon USOctober deal check: compare before you payAmazon US: current deals, useful picks and tech finds.Check DealsSlow PC?RecommendedPC slow today? Run a repair scan before it gets worseResolve common Windows issues and optimize system performance.Scan Now×
Skip to content
World desk6 min

Node.js Frameworks from Scratch: Build Metadata-Driven Routing

A practical architecture walkthrough for turning controller and route declarations into a validated Node.js route map at startup.
Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

A metadata-driven Node.js framework lets you declare controllers and routes once, then inspect those declarations at startup to register handlers with an HTTP server. The useful learning goal is a small, understandable framework core—not a production-ready replacement for an established framework. This tutorial sketches that core in TypeScript and makes the lifecycle, metadata rules, and failure cases explicit.

What metadata-driven routing changes

In a hand-wired server, route declarations and application setup tend to accumulate together:

As an Amazon Associate I earn from qualifying purchases.

server.get("/users", userController.list.bind(userController));
server.post("/users", userController.create.bind(userController));
server.get("/users/:id", userController.get.bind(userController));

That is direct and easy to trace, but repeated registration can become tedious as the application grows. A metadata-driven design moves the route facts next to the controller methods. The framework later reads those facts and builds the route map.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Metadata is configuration, not magic: a decorator or registration function records information, and a bootstrap phase interprets it. NestJS documents attaching custom metadata to classes or handlers and retrieving it later through a reflection API; its documentation also shows that class-level and handler-level metadata need deliberate override or merge rules (NestJS execution context).

Define a small, explicit metadata contract

Start with only what routing needs: a controller base path and a list of method/path/handler declarations. Keep the contract explicit so it can be validated without relying on inferred parameter types.

type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";

type RouteDefinition = {
  method: HttpMethod;
  path: string;
  propertyKey: string | symbol;
};

type ControllerDefinition = {
  basePath: string;
  routes: RouteDefinition[];
};

The framework needs a registry that associates a controller constructor with its definition. A JavaScript WeakMap is a suitable in-memory store because the constructor itself is the lookup key:

const controllers = new Set<Function>();
const definitions = new WeakMap<Function, ControllerDefinition>();

function definitionFor(target: Function): ControllerDefinition {
  let definition = definitions.get(target);
  if (!definition) {
    definition = { basePath: "", routes: [] };
    definitions.set(target, definition);
  }
  return definition;
}

This example uses explicit metadata fields; the framework does not need TypeScript to infer request validation rules from method signatures.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Record controller and route declarations

With TypeScript’s legacy decorator model, a class decorator can register the controller and its base path, while a method decorator records route details. The method decorator receives the prototype and method key, so it can add a declaration without instantiating the controller.

function Controller(basePath: string): ClassDecorator {
  return (target) => {
    const definition = definitionFor(target);
    definition.basePath = basePath;
    controllers.add(target);
  };
}

function Route(method: HttpMethod, path: string): MethodDecorator {
  return (target, propertyKey) => {
    const constructor = target.constructor;
    const definition = definitionFor(constructor);
    definition.routes.push({ method, path, propertyKey });
  };
}

const Get = (path: string) => Route("GET", path);
const Post = (path: string) => Route("POST", path);

@Controller("/users")
class UserController {
  @Get("")
  list() {
    return [];
  }

  @Get("/:id")
  get() {
    return { id: "example" };
  }

  @Post("")
  create() {
    return { created: true };
  }
}

Decorator syntax is only one expression of the contract. A framework could instead expose explicit calls that register the same controller and route records. That alternative can be useful when JavaScript support or simpler tooling matters more than declarative syntax.

Configure TypeScript deliberately

The TypeScript Handbook describes decorator support using the legacy decorator model as experimental. Its examples use experimentalDecorators; design-type metadata emission additionally uses emitDecoratorMetadata and imports reflect-metadata. The handbook cautions that this metadata mechanism is not part of the ECMAScript standard and may change (TypeScript Handbook: Decorators).

// tsconfig.json (relevant options)
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

For this router, the registry records its own route metadata, so it does not need emitted design types at all. Avoid enabling type metadata unless another feature actually consumes it. If you adopt that feature, document the expected compiler options, module setup, and runtime import as part of the framework’s supported toolchain. Do not assume decorator syntax or emitted metadata behaves identically across TypeScript configurations.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Bootstrap by resolving and binding routes

The bootstrap phase should instantiate each registered controller, combine its base path with each route path, resolve the method, and register a bound handler with a server adapter. NestJS documentation likewise presents application setup as more than decorators alone: an application needs an adapter and supporting setup (NestJS application setup documentation).

type ServerAdapter = {
  register(method: HttpMethod, path: string, handler: (...args: any[]) => any): void;
};

function joinPaths(base: string, route: string): string {
  const combined = `/${base}/${route}`.replace(//+/g, "/");
  return combined.length > 1 ? combined.replace(//$/, "") : "/";
}

function bootstrap(adapter: ServerAdapter): void {
  const registered = new Set<string>();

  for (const ControllerType of controllers) {
    const definition = definitions.get(ControllerType);
    if (!definition) throw new Error(`Missing controller metadata: ${ControllerType.name}`);
    if (!definition.basePath.startsWith("/")) {
      throw new Error(`Controller path must start with '/': ${ControllerType.name}`);
    }

    const instance = new (ControllerType as new () => object)();

    for (const route of definition.routes) {
      const key = route.propertyKey;
      const candidate = (instance as any)[key];
      if (typeof candidate !== "function") {
        throw new Error(`Route handler is not a method: ${ControllerType.name}.${String(key)}`);
      }
      if (!route.path.startsWith("/") && route.path !== "") {
        throw new Error(`Route path must start with '/': ${String(key)}`);
      }

      const path = joinPaths(definition.basePath, route.path);
      const identity = `${route.method} ${path}`;
      if (registered.has(identity)) throw new Error(`Duplicate route: ${identity}`);
      registered.add(identity);
      adapter.register(route.method, path, candidate.bind(instance));
    }
  }
}

The adapter is intentionally abstract: a real implementation must translate the resolved method and path into the API of the chosen HTTP server. This separation keeps declaration discovery independent from the transport, but it does not implement request parsing, response formatting, error handling, or shutdown.

Make invalid declarations fail at startup

Startup is the right time to catch configuration mistakes before the server accepts traffic. The sketch rejects missing metadata, invalid path forms, non-method handlers, and duplicate method/path pairs. A usable implementation should also define how it handles these cases:

  • Path normalization: Decide whether trailing slashes are equivalent and whether parameter syntax is validated by the adapter or framework.
  • Duplicate semantics: Decide whether two declarations for the same method and resolved path are always an error. Failing clearly is safer than silently letting registration order decide.
  • Controller inheritance: Specify whether subclasses inherit route metadata, replace it, or merge it. If metadata can come from both a base class and an overriding method, define which declaration wins.
  • Construction and dependencies: The example assumes a zero-argument controller constructor. Constructor injection requires a separate dependency-resolution mechanism.
  • Failure reporting: Include the controller, method, and conflicting route in errors so the owner can correct the declaration.

NestJS’s metadata documentation demonstrates that overriding one metadata value and merging class- and handler-level values are distinct policies, not automatic consequences of using decorators (NestJS reflection and metadata).

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

What the design gains—and what it costs

Concern Handwritten registration Metadata-driven registration
Route visibility Routes appear in the registration code. Route declarations live near handlers; the final map depends on bootstrap resolution.
Flexibility Registration logic is directly editable and explicit. Conventions simplify declarations, while framework rules govern resolution.
Startup checks Checks can be added where routes are registered. A central discovery phase can validate all declarations before serving traffic.
Runtime assumptions Can use plain JavaScript and the chosen server API. Custom metadata can be explicit; emitted TypeScript design metadata adds compiler and runtime assumptions.
Operational scope Application code supplies the surrounding lifecycle and policies. The framework must still supply or specify errors, testing, lifecycle, and shutdown behavior.

No performance or productivity advantage follows automatically from either structure. Those outcomes depend on the implementation and workload; the cited documentation describes patterns and setup, not comparative measurements.

Build a learning framework or adopt an established one?

A small custom framework is useful when the objective is to understand registration, metadata, bootstrap, and adapter boundaries—or when a deliberately narrow application needs a tailored convention. It also leaves you responsible for every policy and operational feature not included in the core.

For an application that needs a wider set of server-side conventions and infrastructure, evaluate an established framework such as NestJS. Its current documentation describes an application architecture and setup process; its execution-context guide illustrates metadata access and resolution policies (NestJS setup; NestJS execution context). A custom tutorial core is a learning exercise, not evidence that recreating those surrounding capabilities is inexpensive.

Other ecosystem projects demonstrate declarative routing as a pattern, including Resty.js, whose README shows a decorated controller registered with an application instance (Resty.js project README). That example establishes the pattern exists; it is not evidence of project maturity, performance, or production suitability. StreetJS also describes decorator-driven controllers, but version-specific compatibility should be checked against its current project documentation rather than inferred from an older listing (StreetJS documentation).

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Leave a Reply

Your email address will not be published. Required fields are marked *

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

More from the Wire

  1. World desk4 min
    How to Spot an AI Voice Scam Before Sending MoneyDon’t rely on how a caller sounds. Pause, call back through a known number, and verify the emergency with another trusted person before sending money.
  2. Mountain View desk4 min
    Google’s SynthID Detector: How to Check AI-Generated Images, Video and AudioGoogle’s SynthID Detector looks for an embedded watermark in supported images, video and audio. Here is what its results do—and do not—show.
  3. Redmond desk20 min
    How to create a link to File or Folder in Windows 11Windows 11 gives you several ways to point to a file or folder without moving or duplicating it. You can create a desktop shortcut,…
Recommended PC Tool
Recommended PC Tool
Outdated Drivers Are Slowing You DownFree scan - exact matches
PC Slower Than It Used to Be?Free scan - under a minute

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.