
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
fullAddressfield from nestedaddressdata
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— validatesid, loadshttps://jsonplaceholder.typicode.com/users/:id, addsfullAddressGET /users/:id/posts— same validation, loads the user and their posts in parallel, merges them, and addsfullAddress
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:
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.
// 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:
npm install dotenv @appsignal/nodejs
For local development, a .env file in the project root is a common pattern:
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:
// 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:
{ "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.
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 1–10; this rule still reads well for real gateways):
// 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:
// 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:
// 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:
{ "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:
// 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):
{ "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.

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):
// 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 retries —
AbortSignal.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:
- Subscribe to our JavaScript Sorcery newsletter and never miss an article again.
- Start monitoring your JavaScript app with AppSignal.
- Share this article on social media
Most popular Javascript articles

Top 5 HTTP Request Libraries for Node.js
Let's check out 5 major HTTP libraries we can use for Node.js and dive into their strengths and weaknesses.
See more
When to Use Bun Instead of Node.js
Bun has gained in popularity due to its great performance capabilities. Let's see when Bun is a better alternative to Node.js.
See more
How to Implement Rate Limiting in Express for Node.js
We'll explore the ins and outs of rate limiting and see why it's needed for your Node.js application.
See more

Gabor Koos
Become our next author!
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!

