hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses URL mastery with @hectoday/http

Understanding URLs

  • What is a URL?
  • The URL constructor
  • URL properties deep dive

URLSearchParams

  • URLSearchParams basics
  • Modifying search params
  • Iterating over params
  • URL and SearchParams together

Encoding

  • Encoding and special characters

Inside @hectoday/http

  • How @hectoday/http parses queries
  • Routing and URL patterns
  • The Request object and URLs
  • Building API URLs
  • Input validation and query schemas

Putting it all together

  • Capstone: bookmarks API

Routing and URL patterns

You’ve seen how @hectoday/http parses query strings. But before it even gets to the query string, the framework needs to answer a more fundamental question: which function should handle this request?

That’s what routing does. It looks at the URL’s pathname and the HTTP method, and decides which handler to run.

What is routing?

When a request comes in, the server sees something like GET /users/42?sort=name. Two pieces of information determine where this request goes:

  1. The HTTP method: GET, POST, PUT, DELETE, etc.
  2. The pathname: /users/42

Routing is the system that maps these two things to a handler function. A GET to /users might return a list of users. A POST to /users might create a new one. Same pathname, different method, different behavior.

@hectoday/http uses the rou3 library under the hood to do this matching efficiently.

Defining routes with route

@hectoday/http exports a route object with methods for each HTTP verb:

import { route } from "@hectoday/http";

const helloRoute = route.get("/hello", {
  resolve: (c) => Response.json({ message: "Hello!" }),
});

const createUser = route.post("/users", {
  resolve: (c) => Response.json({ created: true }, { status: 201 }),
});

Let’s look at the first one closely. route.get("/hello", { ... }) says: “When someone makes a GET request to /hello, run this resolve function.” The resolve function receives a context object c and must return a Response.

What does route.get() actually return? A RouteDescriptor, which is a small object that looks like this:

{ method: "GET", path: "/hello", config: { resolve: ... } }

It doesn’t run anything yet. It’s just a description that says “here’s what to do when this method and path match.” Think of it like a recipe card. The card describes the dish, but nobody is cooking yet.

Available methods

route.get(path, config); // GET requests
route.post(path, config); // POST requests
route.put(path, config); // PUT requests
route.patch(path, config); // PATCH requests
route.delete(path, config); // DELETE requests
route.head(path, config); // HEAD requests
route.options(path, config); // OPTIONS requests
route.all(path, config); // Any HTTP method

Dynamic path parameters

So far, our paths have been static strings like "/hello" or "/users". But what if you need to handle /users/1, /users/42, /users/999, and every other user ID?

You don’t create a separate route for each one. Instead, you use a dynamic segment, a placeholder that matches any value:

route.get("/users/:id", {
  resolve: (c) => {
    const params = c.input.params;
    return Response.json({ userId: params.id });
  },
});

The :id part is the dynamic segment. When someone requests /users/42, the router matches this route and extracts "42" as the value of id. Your handler can then access it through c.input.params.id.

What do you think params.id would be if someone requested /users/alice? It would be the string "alice". Dynamic params always come in as strings. There’s no automatic type conversion (we’ll add that with Zod later).

Multiple dynamic segments work too:

route.get("/users/:userId/posts/:postId", {
  resolve: (c) => {
    // /users/42/posts/7 → { userId: "42", postId: "7" }
    return Response.json(c.input.params);
  },
});
ℹ Note

Without a Zod schema, c.input.ok is always true and params are raw strings. In a later lesson, we’ll add schemas that validate and coerce these into proper types.

How this connects to URLs

Remember how URL.pathname gives you the path portion of a URL?

const url = new URL("https://api.com/users/42/posts/7?sort=new");
url.pathname; // "/users/42/posts/7"

This is exactly what the router looks at. Inside setup(), the framework does:

const url = new URL(request.url);
const matched = findRoute(router, request.method, url.pathname);

It extracts the pathname from the full URL, then asks rou3 to find a matching route. The query string (?sort=new) is completely ignored during routing. It’s parsed separately by parseQuery() after the route has been matched.

Wildcard routes

You can use /** to match any path:

route.get("/api/**", {
  resolve: (c) => Response.json({ message: "API catch-all" }),
});

This matches /api/anything, /api/deeply/nested/path, and everything else that starts with /api/. It’s useful for fallback handlers or catch-all routes that need to handle an entire path tree.

Grouping routes

The group() function lets you organize related routes together:

import { route, group } from "@hectoday/http";

const userRoutes = group([
  route.get("/users", {
    resolve: (c) => Response.json({ users: [] }),
  }),
  route.post("/users", {
    resolve: (c) => Response.json({ created: true }, { status: 201 }),
  }),
  route.get("/users/:id", {
    resolve: (c) => {
      return Response.json({ id: c.input.params.id });
    },
  }),
]);

Right now, group() simply returns the array as-is. It’s a named concept and a future extension point. But it makes your code self-documenting: anyone reading it knows these routes belong together as a logical unit.

Registering routes with setup()

Route descriptors don’t do anything on their own. They’re just descriptions. To make them active, you register them with setup():

import { setup, route, group } from "@hectoday/http";

const app = setup({
  routes: [
    ...userRoutes,
    route.get("/health", {
      resolve: () => Response.json({ status: "ok" }),
    }),
  ],
});

setup() takes all the route descriptors, registers them with the rou3 router, and returns an App object with a fetch handler. From this point on, the app can receive requests and route them to the right handlers.

The ...userRoutes spread syntax unpacks the grouped routes into the main routes array. You could also pass them individually, but grouping keeps things organized as your app grows.

Now that you understand how routes are matched, let’s look at the objects that flow through them: Request and Response. That’s the next lesson.

When @hectoday/http matches a request to a route, which part of the URL does it look at?

← How @hectoday/http parses queries The Request object and URLs →

© 2026 hectoday. All rights reserved.