---
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-08-04"
---

# 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.

## Why this matters: 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.

## What you get: 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**

```typescript
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**

```typescript
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(),
  });
}
```

Building blocks used throughout: Fetch · Crypto · Streams, `ZuploRequest` /
`ZuploContext`, `context.invokeRoute`, `context.custom` shared state, type-safe
`TOptions`, and 1–5 ms latency for most policies.

## What makes Zuplo different: 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.

## FAQ

**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 a free account: https://portal.zuplo.com/signup
- Read the policies documentation: https://zuplo.com/docs/articles/policies
- See GitOps deploy: [/features/gitops](/features/gitops)
