Ratelock

Integrations

Framework Recipes

Framework Recipes

Web-standard frameworks (Next.js, SvelteKit, Astro, Remix) share one shape: handlers receive a standard Request and return a standard Response. There is no middleware pipeline to hook into, so instead of a dedicated package you wire a limiter directly inside each handler.

This page shows the full production pattern for each framework: lazy engine initialization, denial responses and rate limit headers.

Shared building block

Every recipe below reuses two things: a lazily initialized limiter, and a small header helper. Put them in a shared module, for example lib/rate-limit.ts:

import { fixedWindow } from '@ratelock/redis'
import type { BaseResult } from '@ratelock/core'

// Module scope: initialized once per server instance.
const getLimiter = (() => {
    let pending: Promise<ReturnType<typeof fixedWindow>> | undefined

    return () => {
        pending ??= fixedWindow({
            url: process.env.REDIS_URL!,
            limit: 100,
            windowMs: 60_000,
        })
        return pending
    }
})()

function rateLimitHeaders(result: BaseResult & Record<string, unknown>): Record<string, string> {
    const headers: Record<string, string> = {}

    if (typeof result.remaining === 'number') {
        headers['RateLimit-Remaining'] = String(result.remaining)
    } else if (typeof result.tokens === 'number') {
        headers['RateLimit-Remaining'] = String(Math.floor(result.tokens))
    }

    const rawReset =
        typeof result.reset === 'number'
            ? result.reset
            : typeof result.refillTime === 'number'
              ? result.refillTime
              : undefined

    if (rawReset != null) {
        const seconds =
            rawReset > 1e12 ? Math.ceil((rawReset - Date.now()) / 1000) : Math.ceil(rawReset / 1000)
        headers['RateLimit-Reset'] = String(Math.max(0, seconds))
    }

    return headers
}

export { getLimiter, rateLimitHeaders }

A denied request should also carry Retry-After. Compute it from the same fields:

function retryAfterSeconds(result: BaseResult & Record<string, unknown>): number | undefined {
    const rawReset =
        typeof result.reset === 'number'
            ? result.reset
            : typeof result.refillTime === 'number'
              ? result.refillTime
              : undefined
    if (rawReset == null) return undefined
    const seconds =
        rawReset > 1e12 ? Math.ceil((rawReset - Date.now()) / 1000) : Math.ceil(rawReset / 1000)
    return Math.max(0, seconds)
}

export { retryAfterSeconds }

Next.js

App Router route handlers receive a web-standard Request (or NextRequest) and return a Response.

// app/api/things/route.ts
import type { NextRequest } from 'next/server'
import { getLimiter, rateLimitHeaders, retryAfterSeconds } from '@/lib/rate-limit'

export async function GET(request: NextRequest) {
    const limiter = await getLimiter()
    const id = request.headers.get('x-user-id') ?? 'anon'
    const result = await limiter.check(id)

    if (!result.allowed) {
        return Response.json(
            { error: 'Too Many Requests' },
            {
                status: 429,
                headers: {
                    ...rateLimitHeaders(result),
                    ...(retryAfterSeconds(result) != null
                        ? { 'Retry-After': String(retryAfterSeconds(result)) }
                        : {}),
                },
            }
        )
    }

    return Response.json(
        { things: [] },
        { headers: rateLimitHeaders(result) }
    )
}

For a global interception point, Next.js also supports middleware.ts, which runs before routing on every matched path. It executes in the edge runtime, so pair it with an HTTP-reachable engine rather than a TCP connection.

SvelteKit

Server endpoints in +server.ts files receive an event containing the standard Request.

// src/routes/api/things/+server.ts
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { getLimiter, rateLimitHeaders, retryAfterSeconds } from '$lib/rate-limit'

export const GET: RequestHandler = async ({ request }) => {
    const limiter = await getLimiter()
    const id = request.headers.get('x-user-id') ?? 'anon'
    const result = await limiter.check(id)

    if (!result.allowed) {
        return json({ error: 'Too Many Requests' }, {
            status: 429,
            headers: {
                ...rateLimitHeaders(result),
                ...(retryAfterSeconds(result) != null
                    ? { 'Retry-After': String(retryAfterSeconds(result)) }
                    : {}),
            },
        })
    }

    return json({ things: [] }, { headers: rateLimitHeaders(result) })
}

Astro

API endpoints receive an APIContext whose request is a standard Request.

// src/pages/api/things.ts
import type { APIRoute } from 'astro'
import { getLimiter, rateLimitHeaders, retryAfterSeconds } from '../../lib/rate-limit'

export const GET: APIRoute = async ({ request }) => {
    const limiter = await getLimiter()
    const id = request.headers.get('x-user-id') ?? 'anon'
    const result = await limiter.check(id)

    if (!result.allowed) {
        return Response.json(
            { error: 'Too Many Requests' },
            {
                status: 429,
                headers: {
                    ...rateLimitHeaders(result),
                    ...(retryAfterSeconds(result) != null
                        ? { 'Retry-After': String(retryAfterSeconds(result)) }
                        : {}),
                },
            }
        )
    }

    return Response.json({ things: [] }, { headers: rateLimitHeaders(result) })
}

Remix

Loaders and actions receive { request } and return a Response.

// app/routes/api.things.tsx
import type { LoaderFunctionArgs } from '@remix-run/node'
import { getLimiter, rateLimitHeaders, retryAfterSeconds } from '~/lib/rate-limit'

export async function loader({ request }: LoaderFunctionArgs) {
    const limiter = await getLimiter()
    const id = request.headers.get('x-user-id') ?? 'anon'
    const result = await limiter.check(id)

    if (!result.allowed) {
        return Response.json(
            { error: 'Too Many Requests' },
            {
                status: 429,
                headers: {
                    ...rateLimitHeaders(result),
                    ...(retryAfterSeconds(result) != null
                        ? { 'Retry-After': String(retryAfterSeconds(result)) }
                        : {}),
                },
            }
        )
    }

    return Response.json({ things: [] }, { headers: rateLimitHeaders(result) })
}

Choosing an identifier

Web-standard Request objects do not expose the remote address, so pick an identifier deliberately:

DeploymentRecommended identifier
CloudflareCF-Connecting-IP header (set by Cloudflare itself, trustworthy there)
Vercelx-forwarded-for (last entry) or x-real-ip
Self-hosted behind your own proxyx-forwarded-for, only because you control the proxy chain
Authenticated routesUser id or API key: always more precise than any IP

Never read x-forwarded-for on a server reachable without your proxy in front of it: clients can forge that header per-request and rotate it to bypass limits entirely.

SolidStart

SolidStart API routes follow the same fetch-standard shape (Request in, Response out). The SvelteKit recipe applies verbatim once you swap the route file convention.

How is this guide?

Last updated on

On this page