javascript

Building an API Gateway with Koa and AppSignal

Gabor Koos

Gabor Koos on

Building an API Gateway with Koa and AppSignal

In an API-driven setup, a gateway often sits between clients and backend services: it can validate input, aggregate upstream responses, and give you one place to observe traffic. Koa is a strong fit for that role. Its core stays small, async/await is first-class, and middleware composes in a predictable stack.

In this article, you will build a compact API gateway with Koa that:

  • Proxies and reshapes data from JSONPlaceholder
  • Validates route parameters
  • Adds a derived fullAddress field from nested address data

You will also wire up AppSignal for the Node.js stack. From version 3.x onward, the @appsignal/nodejs package instruments Koa automatically (including middleware spans when you use @koa/router), so you get request metrics and errors without bolting on a separate Koa plugin.

The gateway exposes:

  • GET /users/:id — validates id, loads https://jsonplaceholder.typicode.com/users/:id, adds fullAddress
  • GET /users/:id/posts — same validation, loads the user and their posts in parallel, merges them, and adds fullAddress

By the end, you should have a clear picture of how Koa middleware keeps concerns separated, and how AppSignal helps you see latency and failures when upstreams misbehave.

Setting Up the Project

Scaffold a minimal Koa app with @koa/router for routing.

Install dependencies:

Shell
npm install koa @koa/router

Add an index.js entry point: a server on port 3000 (or process.env.PORT) and a root route that returns JSON.

JavaScript
// index.js const Koa = require("koa"); const Router = require("@koa/router"); const app = new Koa(); const router = new Router(); router.get("/", async (ctx) => { ctx.body = { message: "Koa API Gateway is running." }; }); app.use(router.routes()); app.use(router.allowedMethods()); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); });

PORT stays configurable through the environment; you will load other env vars in the next section.

Integrating AppSignal for Monitoring

Observability matters most in production, but turning it on in development often surfaces slow paths and noisy errors before they reach users. AppSignal is built to be quick to adopt: create an app, add the Node integration, and you get traces, errors, and host-related signals in one place.

Sign up if you have not already, create an organization, and copy your Push API key from the onboarding flow or from your app’s Push & deploy settings (organization-level or app-specific keys both work).

Install AppSignal and dotenv for local configuration:

Shell
npm install dotenv @appsignal/nodejs

For local development, a .env file in the project root is a common pattern:

Shell
APPSIGNAL_PUSH_API_KEY=your-push-api-key APPSIGNAL_APP_NAME=koa-api-gateway

Add appsignal.cjs at the root. Load dotenv before you read process.env in the Appsignal constructor:

JavaScript
// appsignal.cjs require("dotenv").config(); const { Appsignal } = require("@appsignal/nodejs"); new Appsignal({ active: true, name: process.env.APPSIGNAL_APP_NAME, pushApiKey: process.env.APPSIGNAL_PUSH_API_KEY, });

Preload that file when starting Node so instrumentation registers before your application code runs:

JSON
{ "scripts": { "start": "node --require ./appsignal.cjs index.js" } }

Using a small appsignal.cjs (or appsignal.js) loaded with --require matches how we recommend bootstrapping the Node.js agent: it keeps initialization out of your route files and guarantees load order.

Run npm start, open http://localhost:3000, and you should see the placeholder JSON while AppSignal ingests the first requests.

Notes on Environment Variables

From Node.js 20.6 onward, you can load a .env file without dotenv in some cases. Modules loaded through --require are outside that path, so keep require("dotenv").config() in appsignal.cjs (or otherwise export variables in your shell) so APPSIGNAL_PUSH_API_KEY is defined when Appsignal is constructed.

AppSignal on Windows

The native agent does not support Windows hosts. If you develop on Windows, use WSL, Linux containers, or a remote Linux environment (for example GitHub Codespaces). See our supported operating systems for details.

Understanding Koa's Middleware Model

Koa’s middleware follows an onion model: each layer can run code before await next(), then resume after inner layers finish. That makes cross-cutting work—logging, timing, validation—easy to express without entangling every route handler.

JavaScript
app.use(async (ctx, next) => { console.log(`Request: ${ctx.method} ${ctx.url}`); await next(); console.log(`Status: ${ctx.status}`); });

Compared with Express’s (req, res, next) style, Koa centralizes ctx (request + response), which often reads more cleanly when you both inspect input and adjust the outgoing body in one place.

Building the API Gateway Endpoints

You will add validation, upstream fetches, and response enrichment as small middleware steps, then attach them to the router.

Validation Middleware

Ensure :id is a positive integer (JSONPlaceholder uses numeric ids 110; this rule still reads well for real gateways):

JavaScript
// index.js const validateId = async (ctx, next) => { const id = ctx.params.id; if (!/^[1-9]\d*$/.test(id)) { ctx.status = 400; ctx.body = { error: "Invalid user ID. Must be a positive integer." }; return; } ctx.state.userId = id; await next(); };

Composing the fullAddress Field

Derive a single line from JSONPlaceholder’s address object:

JavaScript
// index.js const composeFullAddress = (address) => { return `${address.suite}, ${address.street}, ${address.city}, ${address.zipcode}`; }; const fullAddress = async (ctx, next) => { if (ctx.state.user) { ctx.state.user.fullAddress = composeFullAddress(ctx.state.user.address); } await next(); };

Implementing GET /users/:id

Fetch the user, then run fullAddress before the terminal handler sends the body:

JavaScript
// index.js const fetchUser = async (ctx, next) => { const userId = ctx.state.userId; const response = await fetch( `https://jsonplaceholder.typicode.com/users/${userId}`, ); if (!response.ok) { ctx.status = response.status; ctx.body = { error: "Failed to fetch user data." }; return; } ctx.state.user = await response.json(); await next(); }; router.get("/users/:id", validateId, fetchUser, fullAddress, async (ctx) => { ctx.body = ctx.state.user; });

Example response for GET /users/2:

JSON
{ "id": 2, "name": "Ervin Howell", "username": "Antonette", "email": "Shanna@melissa.tv", "address": { "street": "Victor Plains", "suite": "Suite 879", "city": "Wisokyburgh", "zipcode": "90566-7771", "geo": { "lat": "-43.9509", "lng": "-34.4618" } }, "phone": "010-692-6593 x09125", "website": "anastasia.net", "company": { "name": "Deckow-Crist", "catchPhrase": "Proactive didactic contingency", "bs": "synergize scalable supply-chains" }, "fullAddress": "Suite 879, Victor Plains, Wisokyburgh, 90566-7771" }

Implementing GET /users/:id/posts

Middleware still runs sequentially on the stack, but a single middleware may perform parallel I/O with Promise.all. Here, user and posts load together:

JavaScript
// index.js const fetchUserAndPosts = async (ctx, next) => { const userId = ctx.state.userId; const [userRes, postsRes] = await Promise.all([ fetch(`https://jsonplaceholder.typicode.com/users/${userId}`), fetch(`https://jsonplaceholder.typicode.com/users/${userId}/posts`), ]); if (!userRes.ok || !postsRes.ok) { ctx.status = 502; ctx.body = { error: "Failed to fetch user or posts data." }; return; } const user = await userRes.json(); const posts = await postsRes.json(); user.posts = posts; ctx.state.user = user; await next(); }; router.get( "/users/:id/posts", validateId, fetchUserAndPosts, fullAddress, async (ctx) => { ctx.body = ctx.state.user; }, );

Example shape (truncated):

JSON
{ "id": 2, "name": "Ervin Howell", "posts": [ { "userId": 2, "id": 11, "title": "et ea vero quia laudantium autem", "body": "delectus reiciendis molestiae occaecati non minima eveniet qui voluptatibus\naccusamus in eum beatae sit\nvel qui neque voluptates ut commodi qui incidunt\nut animi commodi" } ], "fullAddress": "Suite 879, Victor Plains, Wisokyburgh, 90566-7771" }

You now have a gateway that validates input, fans out to upstream HTTP APIs, merges results, and enriches the payload. With AppSignal running, those requests show up as transactions with middleware-level detail, which is useful when one hop adds most of the latency.

Error Handling and Monitoring with AppSignal

Gateways fail in familiar ways: timeouts, unexpected status codes, empty JSON bodies, or shape drift in upstream payloads. A toy bug illustrates why status checks matter.

In fetchUserAndPosts, comment out the guard that verifies userRes.ok and postsRes.ok. GET /users/2/posts may still look fine, but GET /users/11/posts hits a user id JSONPlaceholder does not define. The user request returns 404 with an empty object {}. Downstream code still assumes address exists, so composeFullAddress throws. Koa responds with 500 Internal Server Error, and the stack trace in your terminal is easy to miss once traffic is split across instances.

With AppSignal enabled, that exception surfaces as an error incident with stack and request context so you can jump from “500 on /users/:id/posts” to the exact line. Restore the status check before continuing.

AppSignal error incident for the Koa gateway, showing stack trace and request details
AppSignal error incident with stack trace and request context

Global Error Handling Middleware

Koa benefits from a top-level error wrapper whose try / await next() wraps everything else on the stack. In practice, that means this app.use must be registered immediately after const app = new Koa(), before app.use(router.routes()) and app.use(router.allowedMethods()). If you register it after the router, route and middleware errors will not unwind into this catch block, and clients will see Koa’s generic 500 Internal Server Error body instead of your JSON.

To report handled server-side failures explicitly, import sendError from the same package as Appsignal (alongside your other require calls at the top of index.js):

JavaScript
// index.js — first app.use, after `new Koa()` and before `app.use(router.routes())` const { sendError } = require("@appsignal/nodejs"); app.use(async (ctx, next) => { try { await next(); } catch (err) { console.error("Application error:", err); ctx.status = err.status || 500; ctx.body = { error: true, message: process.env.NODE_ENV === "production" ? "Internal server error" : err.message, timestamp: new Date().toISOString(), }; if (ctx.status >= 500) { sendError(err); } } });

Pair this with the specific response.ok checks you already use in fetchUser and fetchUserAndPosts so clients get 4xx/5xx responses that match upstream semantics instead of opaque 500s whenever JSON shape changes.

Next Steps

This gateway is intentionally small. Practical extensions include:

  • Caching — cache upstream JSON keyed by path and id to cut latency and protect JSONPlaceholder (or your real APIs) from thundering herds
  • Rate limiting — token bucket or fixed window per client id or API key
  • Authentication — verify JWTs, mTLS, or static keys at the edge before fan-out
  • Timeouts and retriesAbortSignal.timeout, bounded retries, and circuit breaking for flaky peers

Conclusion

Koa’s middleware model keeps validation, upstream calls, and response shaping in focused units instead of one giant handler. AppSignal’s Node.js agent adds Koa-aware tracing and error reporting with little ceremony, which matters most when your gateway is the first place users notice upstream outages.

Together, they give you a maintainable pattern for edge APIs and enough visibility to tighten hot paths and harden error handling as traffic grows.

Wondering what you can do next?

Finished this article? Here are a few more things you can do:

  • Share this article on social media

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps