---
title: "TypeScript-Programmable API Gateway"
description: "Write custom inbound policies, outbound policies, and request handlers in TypeScript. Type-safe @zuplo/runtime SDK, Web Standard APIs (Fetch, Crypto, Streams, URL), V8 isolates at the edge across 300+ POPs. Compose other routes with context.invokeRoute, share state via context.custom — no proprietary DSL."
canonicalUrl: "https://zuplo.com/features/programmable"
sourceUrl: "https://zuplo.com/features/programmable"
pageType: "feature"
generatedAt: "2026-10-08"
---

# Gateway logic in TypeScript, not a DSL

> Every gateway handles the happy path; your real requirements are the edge cases declarative JSON config can't express. When they hit, drop into real TypeScript at the edge — no proprietary DSL, no separate Lambda.

## TypeScript at the edge

V8 isolates · 300+ POPs · sub-ms most policies

The hero shows four example files, each with its measured overhead:

- `geo-block-inbound.ts` (inbound policy) — 403 Forbidden for sanctioned regions, +1.2 ms
- `enrich-with-customer-inbound.ts` (inbound policy) — tier injected into context + header, +8 ms (warm fetch)
- `redact-pii-outbound.ts` (outbound policy) — PII redacted on response body, +2.4 ms
- `aggregate-handler.ts` (custom handler) — BFF response composed in-gateway, +3 ms (parallel)

Badges: V8 isolates · web-standard APIs; Type-safe · @zuplo/runtime; ~1–5 ms · most policies.

## Every gateway DSL bottoms out the same way: write a Lambda

Whatever the gateway can't express becomes a sidecar service. Once you're stitching sidecars, the gateway has stopped being a gateway and started being a routing layer in front of your real architecture.

- **Proprietary DSLs that fight you on day two.** The first rule looks easy. The third rule is a Stack Overflow excavation. The seventh requires a vendor consultant. You wanted gateway logic; you got a custom programming language with no debugger.
- **"We need a Lambda for that."** Custom logic the gateway can't express moves to a Lambda. Now there's a network hop, a separate deploy pipeline, a separate IAM role, and a separate place to look when something breaks at 2am.
- **Gateway code lives outside Git history.** Policies were clicked together in a UI. There's no diff for the change that broke production. Reverting means clicking through the audit trail, hoping nothing else changed at the same time.
- **Test? In production, mostly.** There's no way to unit-test the proprietary policy language. "Testing" is deploying to staging and hitting endpoints. The fast feedback loop your application code has at the gateway tier doesn't exist.

## Real code, real APIs, real performance

- **TypeScript, the language you already use.** Custom policies are typed TypeScript functions imported from `@zuplo/runtime`. Code review them in the same PR as your application code. Test the policy's behavior at the gateway with the built-in `zuplo test` runner, and unit-test the pure helpers it calls with Jest or Vitest. Debug them with the same instincts you use everywhere else.
- **Full request and response context.** Mutate the request before the handler. Transform the response before send. Read API key metadata, consumer attributes, geo, environment vars. Pass enrichment data between policies via `context.custom`. Log structured events with `context.log`.
- **Runs at the edge in 300+ POPs.** Custom code runs in the same V8 isolates as Zuplo's built-in policies, on the same edge runtime, in 300+ data centers. Most policies add 1–5 ms. No extra network hop, no Lambda, no separate deploy.

## Inbound, outbound, handler — all just TypeScript

Build an inbound policy that enriches every request with consumer metadata. Compose a BFF that aggregates three downstream routes in one handler. All in TypeScript, typed end-to-end, deployed via GitOps.

Custom inbound policy · TypeScript

```ts
import type { ZuploRequest, ZuploContext } from "@zuplo/runtime";

interface Options {
  internalApiUrl: string;
}

export default async function enrichWithCustomer(
  request: ZuploRequest,
  context: ZuploContext,
  options: Options,
) {
  const consumer = request.user?.sub;
  if (!consumer) return request;

  const url = options.internalApiUrl + "/" + consumer;
  const res = await fetch(url);
  const customer = await res.json();

  // Pass enrichment data to downstream policies + handler
  context.custom.customer = customer;
  request.headers.set("x-customer-tier", customer.tier);
  return request;
}
```

BFF handler · invokeRoute composition

```ts
import type { ZuploRequest, ZuploContext } from "@zuplo/runtime";

export default async function dashboardHandler(
  _request: ZuploRequest,
  context: ZuploContext,
) {
  // Each invokeRoute call runs the full inbound + outbound
  // pipeline for that route — auth, rate limit, validation —
  // without leaving the gateway.
  const [orders, profile, billing] = await Promise.all([
    context.invokeRoute("/v1/orders"),
    context.invokeRoute("/v1/profile"),
    context.invokeRoute("/v1/billing"),
  ]);

  return Response.json({
    orders: await orders.json(),
    profile: await profile.json(),
    billing: await billing.json(),
  });
}
```

Badges: Fetch · Crypto · Streams; ZuploRequest / ZuploContext; context.invokeRoute; context.custom shared state; Type-safe TOptions; 1–5 ms · most policies.

## The skills you have, applied to the gateway tier

- **Web-standard APIs, not a vendor SDK.** fetch, Headers, Request, Response, Web Crypto, Streams, URLPattern, TextEncoder/Decoder. Anything that runs in modern Chrome's Worker context generally runs in Zuplo. Skills transfer. Stack Overflow answers transfer.
- **context.invokeRoute · compose without leaving the gateway.** Build a BFF in one handler that aggregates orders, profile, and billing routes — each one running through its own policies, all in-process. Build a multi-step MCP tool that orchestrates real APIs. No HTTP hops, no parallel infrastructure.
- **Type-safe options end-to-end.** Declare a `TOptions` type in your policy. Reference the same JSON shape in `config/policies.json`. The types are validated at build, the values at deploy. Misconfiguring a policy is a build error, not a runtime surprise.
- **Share policies as npm packages.** Publish your team's custom policies as an internal npm package. Consume them from any Zuplo project. Version-pin, semver, automate updates with Renovate or Dependabot — everything you already do for app code, applied to gateway code.

## What teams use this for

- **"We need a tier-aware response transformer."** Inbound policy reads the consumer's tier from API key metadata and writes it to `context.custom.tier`. Outbound policy reads `context.custom.tier` and redacts PII fields if the tier is free. Two policies, ~20 lines each, no separate service.
- **"The mobile app needs an aggregated dashboard payload."** Custom handler calls `context.invokeRoute("/v1/orders")`, `context.invokeRoute("/v1/profile")`, and `context.invokeRoute("/v1/billing")` in parallel — each one running through its own auth, rate-limit, and validation policies — then assembles the response. No BFF service to maintain.
- **"We need to enrich every request with internal customer data."** Inbound policy hits your internal `/customers/{sub}` endpoint via `fetch`, attaches the result to `context.custom.customer`, and forwards. The handler and downstream policies read it without re-fetching. Cache the lookup with a Map for warm-worker reuse.
- **"Compliance wants every request body archived to S3."** Outbound policy stringifies request + response, posts to your S3 bucket via the AWS SDK or a signed URL — same pattern documented for Azure Blob and S3. Async, no impact on response latency. Audit trail lives in your bucket, queryable with Athena.

## Frequently Asked Questions

### What is a programmable API gateway?

A programmable API gateway lets you write custom logic — auth, transformations, rate limits, orchestration — in real code instead of a vendor-specific DSL or YAML. Zuplo runs your custom code as TypeScript in lightweight V8 isolates at the edge, with web-standard APIs (Fetch, Crypto, Streams). You get the flexibility of writing a Lambda without the deployment overhead, the cold starts, or the round-trip from your gateway to your function.

### How do I write custom logic in an API gateway?

Zuplo gives you four extension points, all in TypeScript: custom inbound policies (run before your handler), outbound policies (run after), request handlers (produce the response yourself for BFF or orchestration), and lifecycle hooks. Each is a standard async function with strongly-typed request and context arguments. No proprietary scripting language to learn — if you can write Node-style TypeScript, you can extend the gateway.

### Can I call other APIs from inside the gateway?

Yes — fetch is built in. Custom code can call third-party APIs, your own services, or even other routes on the same gateway. Zuplo also supports orchestration via context.invokeRoute("/v1/orders") which runs another route's full inbound+outbound pipeline without an HTTP hop. Use this for BFF aggregation, MCP tool composition, or fan-out patterns where one inbound request becomes several backend calls.

### How do I build a Backend-for-Frontend (BFF) at the gateway?

Define a route handler that aggregates calls to multiple backend services into one client-shaped response. Your TypeScript handler can fetch user, orders, and recommendations in parallel, merge the data, transform field names, strip internal IDs, and return exactly what the frontend needs. Because it runs in the gateway runtime, your auth and rate limits already apply — and the latency is one edge round-trip, not three.

### How fast are custom policies on Zuplo?

The base gateway runs in 20–30 ms with no policies. Most custom policies add 1–5 ms. Header manipulation and basic validation: 0–3 ms. API key auth and rate limiting: 3–10 ms. Larger transforms or external API calls: 10–20+ ms. Tested throughput exceeds 10,000 RPS per region with policies enabled. Cold start is ~100–200 ms on Managed Edge; no cold start on Managed Dedicated. Production-grade numbers, not prototype.

### Can I unit-test custom gateway policies?

Yes, at two levels. Pure helper functions your policy calls are plain TypeScript, so unit-test them with Jest, Vitest, or any framework you already use. For the policy's actual behavior at the gateway, use the built-in zuplo test runner: plain TypeScript test files in your repo's tests/ folder that make real HTTP requests and run against local dev, against the real preview environment every Git branch deploys to at a .zuplo.app URL, or against production. PR previews catch policy regressions before they merge.

### How do I share custom code across multiple gateway projects?

Package your custom policies and handlers as an npm module. Any Zuplo project that depends on the package gets the code in its modules/ directory and references the policies by name. Version pinning works the way you'd expect — bump the package, every consuming gateway picks up the new code on its next deploy. Useful for platform teams with shared auth or audit policies across many gateways.

### What's the best programmable API gateway?

Look for: real code (TypeScript or your language of choice, not a DSL), web-standard APIs (Fetch, Crypto, Streams) so the code is portable, fast cold starts, low per-policy latency, and the ability to compose other routes. Zuplo runs TypeScript in V8 isolates across 300+ POPs and is the gateway of choice for teams who want gateway logic to feel like backend code, not gateway config.

## Next steps

- [Start for Free](https://portal.zuplo.com/signup) — "Free Zuplo project, write your first custom inbound policy in TypeScript, and ship it to the edge in an afternoon."
- [Read the Docs](https://zuplo.com/docs/articles/policies)
- [See GitOps Deploy](/features/gitops)
