← All posts
Scaffy

Why a catch-all API Gateway route made me write my own router

Jun 17, 2026 · 10 min read · by Henners
One catch-all /api/{proxy+} route flowing into router.ts, which fans out to an ordered list of routes with the first matching pattern highlighted

Last time I wrote about how Scaffy’s adapters get wired together: the config picks the implementations, and a factory builds the runtime ones once per cold start. So by the time a request actually arrives, the Lambda already has a live database adapter in hand. This post is about what happens next: how that one request finds the right piece of code to run.

The short version is that I gave API Gateway exactly one route and did the rest myself. Here’s why that turned out to be the simpler option, not the lazier one.

One route, any method, one Lambda

The whole backend sits behind a single HTTP API route: a catch-all proxy that forwards anything under /api to one Lambda, for any method.

// CDK stack (deploy time): one route, any method, into one Lambda
api.addRoutes({
  path: "/api/{proxy+}",
  methods: [apiGateway.HttpMethod.ANY],
  integration: contentIntegration,
});

That {proxy+} is the catch-all. API Gateway stops trying to understand the URL past /api and hands the whole thing to the Lambda: the method, the raw path, the query string, the body. Which is great, except now nothing has decided which bit of code should run. That decision is mine to make, inside the function.

Why not let API Gateway route?

HTTP APIs can absolutely route for you. You can declare GET /api/schemas/{schema} and GET /api/{collection} as separate routes with their own integrations, and path parameters come out parsed for free. I started down that road and backed out for two reasons.

The first is precedence. A pattern is just its segments split on the slashes, and each segment is one of two things: a literal string that has to match exactly, or a {param} that matches anything and captures whatever was there. The catch is that a single URL can satisfy two patterns at once. Take the request GET /api/schemas/post.

In /api/schemas/{schema}, the middle segment is the literal "schemas", so it only fits a URL that actually says schemas there, and {schema} captures "post". That reads as “list the entries of the post schema”. But /api/{collection}/{id} has a {param} in that same middle slot, so {collection} cheerfully captures the literal "schemas" while {id} captures "post". That reads as “get entry post from a collection named schemas”. Same three segments, two completely different requests.

I always want the literal to win: when one pattern pins a segment to "schemas" and another leaves it open as {collection}, the exact-string match is the more specific one and should beat the {param}. That is purely a question of which pattern I test first, so I’d rather state it as order, in a list I read top to bottom, than hand both to API Gateway’s matcher and hope it breaks the tie the way I meant. When I own the match, “specific beats general” is just “earlier in the array”.

Walk that one request down the table and the rule does its job. Two patterns fit; the matcher checks every route, then dispatches to the first that matched:

Matching GET /api/schemas/post against the four routes in table order. Route [1] /api/schemas/{schema} matches first (the literal "schemas" matches and {schema} captures "post") and wins. Routes [2] and [3] are skipped on segment count. Route [4] /api/{collection}/{id} also matches ({collection} captures "schemas", {id} captures "post") but sits lower in the list, so it never runs. Order alone makes the literal beat the wildcard.

That ordering has a sharp edge worth owning: it makes schemas a reserved word. A collection someone named schemas would still list fine at /api/schemas, but they could never read a single entry from it, because /api/schemas/{id} always resolves to the schema route sitting above it. Rather than let that fail silently, Scaffy rejects it at config time: name a collection after a literal route segment and validation fails fast with Collection "schemas" uses a reserved name. The reserved list is kept in sync with the route table so the guard and the routes can’t drift.

The second is that I didn’t want my routing table living half in CDK and half in handlers. If API Gateway does the routing, every path gets declared once in the CDK stack so the gateway will accept it, then declared again in the Lambda to map each routeKey back to a handler:

// CDK stack (deploy time): declare every path so the gateway accepts it
for (const path of [
  "/api/schemas/{schema}",
  "/api/{collection}",
  "/api/{collection}/{id}",
  // ...one entry per route
]) {
  api.addRoutes({ path, methods: [apiGateway.HttpMethod.ANY], integration });
}

// backend Lambda (request time): the same paths again, mapping each routeKey to a handler
const handlers = {
  "GET /api/schemas/{schema}": listSchemaEntriesHandler,
  "GET /api/{collection}": listCollectionEntriesHandler,
  "GET /api/{collection}/{id}": getCollectionEntryHandler,
  // ...
};
const handler = handlers[event.routeKey];

Two copies, in two packages, on two lifecycles to keep in sync. Miss the infra side and the gateway 404s before the Lambda ever runs; miss the handler side and the gateway accepts a request the Lambda has no code for. The catch-all collapses that to one route in CDK and one table in the backend: adding an endpoint is a new line in that one routes array, not a new piece of infrastructure.

A route table in plain TypeScript

So the router is just a list. Each route is a method, a path pattern, and the handler to call. The ordering is load-bearing, which is the one thing worth a comment.

type Route = {
  method: "GET" | "POST" | "PUT" | "DELETE";
  path: string;
  handler: RouteHandler;
};

// Order matters: a static segment must beat the wildcard.
const routes: Route[] = [
  { method: "GET", path: "/api/schemas/{schema}", handler: listSchemaEntriesHandler },
  { method: "GET", path: "/api/schemas/{schema}/{id}", handler: getSchemaEntryHandler },
  { method: "GET", path: "/api/{collection}", handler: listCollectionEntriesHandler },
  { method: "GET", path: "/api/{collection}/{id}", handler: getCollectionEntryHandler },
  // ...the POST/PUT/DELETE writes, under /admin
];

Because the router walks this array top to bottom and takes the first match, putting the schemas routes above the collection routes is the entire precedence rule. No regex weights, no specificity scoring. Just read order.

Matching a path

Matching is a segment comparison. Split both the pattern and the real path on slashes, drop empties, and bail immediately if they’re different lengths. Then every segment has to either be a {parameter}, which matches anything, or be an exact text match. If it survives that, I pull the parameter values back out and decode them.

const isParameterSegment = (segment: string) => segment.startsWith("{") && segment.endsWith("}");

const matchPath = (
  pattern: string,
  path: string,
): Record<string, string> | undefined => {
  const patternSegments = pattern.split("/").filter(Boolean);
  const pathSegments = path.split("/").filter(Boolean);
  if (patternSegments.length !== pathSegments.length) return undefined;

  const segmentPairs = patternSegments.map((segment, index) => ({
    segment,
    value: pathSegments[index],
  }));

  const isMatch = segmentPairs.every(
    ({ segment, value }) => isParameterSegment(segment) || segment === value,
  );
  if (!isMatch) return undefined;

  return Object.fromEntries(
    segmentPairs
      .filter(({ segment }) => isParameterSegment(segment))
      .map(({ segment, value }) => [segment.slice(1, -1), decodeURIComponent(value)]),
  );
};

The return type does double duty. An undefined means “this route doesn’t apply”, and an object means “it matched, and here are the parameters”. So a single call answers both questions, which makes the dispatch loop fall out cleanly.

Dispatch

createRouter takes the dependencies the factory built at cold start (the database adapter, plus a content-validation service) and returns the actual Lambda handler. On each request it filters by method, runs matchPath against every candidate, takes the first that matched, and merges the extracted parameters onto the event before calling the handler. Nothing matched means a 404.

export const createRouter = (dependencies: HandlerDependencies) => {
  return async (event: APIGatewayProxyEventV2) => {
    const method = event.requestContext.http.method;

    const match = routes
      .map((route) => ({
        route,
        pathParameters:
          route.method === method ? matchPath(route.path, event.rawPath) : undefined,
      }))
      .find(({ pathParameters }) => pathParameters !== undefined);

    if (!match?.pathParameters)
      return { statusCode: 404, body: JSON.stringify({ error: "Route not found" }) };

    const eventWithParameters = {
      ...event,
      pathParameters: { ...event.pathParameters, ...match.pathParameters },
    };

    return match.route.handler(eventWithParameters, dependencies);
  };
};

That’s the join with the last post. createRouter closes over dependencies, so every handler gets the live database adapter (and the other services the factory built) passed in without reaching for a global. The Lambda entry point is then tiny: pull the factory’s exports together and hand them to createRouter.

// lambda.ts: the whole entry point
import { contentValidationService, databaseAdapter } from "./factory";
import { createRouter } from "./router";

export const handler = createRouter({ databaseAdapter, contentValidationService });

Handlers don’t know any of this exists

The payoff is that a handler is just an async function of (event, dependencies). It reads the parameters the router put on the event, pulls the adapter it needs out of dependencies, does its work, and returns a status code and a body. It has no idea it was reached through a catch-all proxy, or that a sixty-line router picked it.

export const getCollectionEntryHandler = async (event, { databaseAdapter }) => {
  const { collection, id } = event.pathParameters ?? {};
  if (!collection || !id)
    return { statusCode: 400, body: JSON.stringify({ error: "Missing collection or id parameter" }) };

  const result = await databaseAdapter.getCollectionEntry(collection, id);
  if (!result.success)
    return { statusCode: 500, body: JSON.stringify({ error: result.errorMessage }) };
  if (!result.data)
    return { statusCode: 404, body: JSON.stringify({ error: "Not found" }) };

  return { statusCode: 200, body: JSON.stringify(result.data) };
};

What it cost, and what’s next

The tradeoff, plainly: this is a linear scan over a hardcoded array, and it does a fresh match on every request. At Scaffy’s scale, a handful of routes and a CMS that mostly serves cached reads, that costs nothing worth measuring, and I’d rather spend zero dependencies and zero bundle weight than pull in a router framework for ten routes. If the table ever grows enough to matter, the array is trivial to index by method first. It hasn’t, so I haven’t.

There’s one thing I skipped past here: every one of these routes is public in this code, but the writes under /admin obviously can’t be. Next time I’ll get into auth, and how those admin routes get locked down so the router never even runs for a request that shouldn’t get in.