Skip to content

Frameworks and the Edge

Ameya Borkar edited this page May 27, 2026 · 3 revisions

Frameworks and the edge

Every adapter shares the same options (strategy/limiter, store, key, cost, fail, emit, onLimited, handler, trusted-proxy config) and the same standards headers — only the binding differs, and each is its own subpath. Beyond Express/fetch/Hono/Fastify/Koa/Next, ThrottleKit ships NestJS, SvelteKit, Remix, Elysia, AWS Lambda, tRPC, and gRPC bindings, plus a transport-agnostic createEnforcer() for anything else — all dependency-free.

Express

import { expressRateLimit } from "throttlekit/express";
import { gcra } from "throttlekit";

app.use(
  expressRateLimit({
    strategy: gcra({ limit: 100, periodMs: 60_000, burst: 20 }),
    // Default key is a proxy-correct, IPv6-aggregated client IP (see Operations).
    key: (req) => req.headers["x-api-key"]?.toString() ?? req.ip ?? "anon",
    cost: (req) => (req.method === "POST" ? 5 : 1),
    fail: "open",            // allow if the store is unreachable ("open" | "closed")
    emit: { draft: true },   // emit IETF draft RateLimit headers (the default)
    onLimited: (req, _res, d) => console.warn("blocked", req.path, d.retryAfterMs),
  }),
);

On a denial the middleware responds 429 with Retry-After. Pass handler to own the 429, or limiter instead of strategy to share a prebuilt limiter. See examples/express.ts.

Web / edge (fetch)

Runs on Cloudflare Workers, Deno, Bun, and Next.js edge. The default key tries cf-connecting-ip, then x-forwarded-for (via the trusted-proxy policy), then "anon".

import { withRateLimit } from "throttlekit/fetch";
import { gcra } from "throttlekit";

export default {
  fetch: withRateLimit(
    (req: Request) => new Response(`hello ${new URL(req.url).pathname}`),
    { strategy: gcra({ limit: 30, periodMs: 10_000 }), emit: { draft: true } },
  ),
};

On allow it forwards to your handler and copies the rate-limit headers onto the response; on deny it returns 429. See examples/fetch-edge.ts.

Hono, Fastify, Koa

import { honoRateLimit } from "throttlekit/hono";       // edge-first
app.use("*", honoRateLimit({ strategy: gcra({ limit: 30, periodMs: 10_000 }) }));

import { fastifyRateLimit } from "throttlekit/fastify";  // Fastify v5
fastify.addHook("onRequest", fastifyRateLimit({ strategy: gcra({ limit: 100, periodMs: 60_000 }) }));

import { koaRateLimit } from "throttlekit/koa";          // Koa v3
app.use(koaRateLimit({ strategy: gcra({ limit: 100, periodMs: 60_000 }) }));

Hono, Fastify, and Koa are optional peer dependencies.

Next.js

Next.js middleware is dependency-free (NextRequest/NextResponse are Web Request/Response) — call the limiter, then branch:

// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { nextRateLimit } from "throttlekit/next";
import { gcra } from "throttlekit";

const limit = nextRateLimit({ strategy: gcra({ limit: 30, periodMs: 10_000 }) });

export async function middleware(req: NextRequest) {
  const r = await limit(req);
  if (r.limited) return r.response;            // 429 (or 503 on a fail-closed outage)
  const res = NextResponse.next();
  for (const [k, v] of Object.entries(r.headers)) res.headers.set(k, v);
  return res;
}

For Next.js route handlers, use throttlekit/fetch directly — they're Web fetch handlers.

NestJS

A CanActivate guard for @UseGuards(...), keyed on the proxy-correct client IP. Dependency-free — pass exceptionFactory to throw a real HttpException for an idiomatic 429:

import { HttpException, HttpStatus } from "@nestjs/common";
import { nestRateLimit } from "throttlekit/nest";
import { gcra } from "throttlekit";

const RateLimitGuard = nestRateLimit({
  strategy: gcra({ limit: 100, periodMs: 60_000 }),
  exceptionFactory: (d) =>
    new HttpException({ error: "Too Many Requests", retryAfterMs: d.retryAfterMs }, HttpStatus.TOO_MANY_REQUESTS),
});

// @UseGuards(RateLimitGuard) on a controller or handler

The @RateLimit decorator (the ergonomic form)

Prefer per-route config like @nestjs/throttler? Register one guard globally and annotate routes with @RateLimit({ limit, period }). Still dependency-free — it reads the ambient reflect-metadata NestJS already loads (no @nestjs/common import):

// app.module.ts — register the guard once
import { APP_GUARD } from "@nestjs/core";
import { createRateLimitGuard } from "throttlekit/nest";
import { RedisStore } from "throttlekit/redis";

@Module({
  providers: [
    { provide: APP_GUARD, useValue: createRateLimitGuard({ store: new RedisStore({ client }) }) },
  ],
})
export class AppModule {}

// any controller — annotate a handler (or the whole class)
import { RateLimit } from "throttlekit/nest";

@Controller("posts")
export class PostsController {
  @RateLimit({ limit: 100, period: "1m" })  // period: "30s" | "1m" | "1h" | "1d" | ms
  @Post() create() { /* ... */ }

  @RateLimit({ strategy: quota({ limit: 1_000_000, resetCadence: "calendar-month" }) })
  @Post("bulk") bulk() { /* a monthly quota on one route */ }
}

Routes without @RateLimit pass through (set defaults on the guard to limit them too). One limiter is built and cached per distinct config, all sharing the guard's store. A handler-level @RateLimit overrides a class-level one.

AWS Lambda / API Gateway

Wrap a proxy handler (REST payload v1 + HTTP API v2). Keyed by the API Gateway-provided sourceIp (platform-set, not client-spoofable); it merges the standards headers into the result and returns 429 over the limit:

import { lambdaRateLimit } from "throttlekit/lambda";
import { gcra } from "throttlekit";
import { RedisStore } from "throttlekit/redis";

export const handler = lambdaRateLimit(myHandler, {
  strategy: gcra({ limit: 100, periodMs: 60_000 }),
  store: new RedisStore({ client }), // a shared store — each invocation is a fresh process
});

tRPC

A middleware (pass it to t.middleware). You supply the key from the app-defined ctx; on a denial it throws — RateLimitExceededError by default, or a TRPCError via errorFactory:

import { TRPCError } from "@trpc/server";
import { trpcRateLimit } from "throttlekit/trpc";

const rl = trpcRateLimit<MyCtx>({
  strategy: gcra({ limit: 100, periodMs: 60_000 }),
  key: ({ ctx }) => ctx.user?.id ?? ctx.ip,
  errorFactory: (d) => new TRPCError({ code: "TOO_MANY_REQUESTS", message: `retry in ${d.retryAfterMs}ms` }),
});
export const limited = t.procedure.use(t.middleware(rl));

gRPC

A unary-handler wrapper for @grpc/grpc-js. Keyed on the peer by default (override to read an API token from metadata); over the limit it completes the call with RESOURCE_EXHAUSTED:

import { grpcRateLimit } from "throttlekit/grpc";
const gate = grpcRateLimit({ strategy: gcra({ limit: 100, periodMs: 60_000 }) });
server.addService(GreeterService, { sayHello: gate.unary(sayHelloImpl) });

SvelteKit, Remix, Elysia

// SvelteKit — src/hooks.server.ts (keyed on event.getClientAddress())
import { sveltekitRateLimit } from "throttlekit/sveltekit";
export const handle = sveltekitRateLimit({ strategy: gcra({ limit: 60, periodMs: 60_000 }) });

// Remix — a loader/action guard; resolves to headers, THROWS a 429 Response over the limit
import { remixRateLimit } from "throttlekit/remix";
const rateLimit = remixRateLimit({ strategy: gcra({ limit: 60, periodMs: 60_000 }) });
export async function loader({ request }) { const headers = await rateLimit(request); return json(data, { headers }); }

// Elysia — an onBeforeHandle hook
import { elysiaRateLimit } from "throttlekit/elysia";
new Elysia().onBeforeHandle(elysiaRateLimit({ strategy: gcra({ limit: 30, periodMs: 10_000 }) })).get("/", () => "ok");

Any transport (createEnforcer)

No adapter for your transport? createEnforcer is the dependency-free core every binding is built on: it turns a key into a verdict + standards headers with the fail policy folded in, so it never throws on a store outage.

import { createEnforcer, gcra } from "throttlekit";
const { enforce } = createEnforcer({ strategy: gcra({ limit: 30, periodMs: 10_000 }) });

const r = await enforce(keyFromMessage(msg)); // { allowed, outcome: "ok" | "limited" | "error", headers, retryAfterMs }
if (!r.allowed) drop(msg);                    // queue consumer, job runner, custom protocol, …

See also

  • Operations — standards headers, trusted-proxy IP keys, PII-safe HMAC keys.
  • Distributed & provable — back any adapter with Redis, Postgres, Cloudflare, DynamoDB, or Deno KV.

Clone this wiki locally