Server

Recipes

The endpoint is the same everywhere; only the sentence that produces a Request differs. Here is that sentence, once per framework, with the file path the framework expects. Checked in September 2026 by installing each framework beside a packed bugbottle and running tsc --noEmit over the snippet: Next 16.3.4, SvelteKit 2.70.3, Nuxt 4.5.2 (h3 1.15.11), Astro 7.3.1, React Router 7.18.3 and 8.3.1, and Hono 4.13.7, all on TypeScript 5.9.

Deno, Bun and Cloudflare Workers are not on the list because they need no recipe: they hand you a web Request and take a Response, so the first example in Receiving a report is already the whole handler.

One option differs per framework, and it is the one under each recipe below: where the caller's address comes from. A web Request has none, so if you use rateLimit you either hand in remoteAddress from whatever the runtime knows or set trustProxy and let a forwarding header name the caller — and the sentence about being wrong in Receiving a report is worth reading before you pick.

Next.js (App Router) #

app/api/feedback/route.ts:

ts
import { handleReport, toResend } from "bugbottle/server";

export const runtime = "nodejs";

export async function POST(request: Request) {
  return handleReport(request, {
    maxBodyBytes: 4 * 1024 * 1024,
    sinks: [
      toResend({
        apiKey: process.env.RESEND_API_KEY!,
        from: "[email protected]",
        to: "[email protected]",
      }),
    ],
  });
}

The line that matters is runtime. "nodejs" is the default and the one to keep when store talks to a database; "edge" runs this handler unchanged, since nothing in bugbottle/server needs a Node built-in. A route handler is handed the body unparsed, so the Pages Router's api.bodyParser.sizeLimit has nothing to do with it and maxBodyBytes is the only ceiling — and the bytes reach handleReport exactly as they were sent, so signature verifies. There is no peer address to hand in — Next removed request.ip in 15 — so behind Vercel or your own proxy the address is trustProxy: true or nothing. The client goes in a "use client" component rendered from the root layout.

SvelteKit #

src/routes/api/feedback/+server.ts:

ts
import type { RequestHandler } from "@sveltejs/kit";
import { handleReport, toWebhook } from "bugbottle/server";

export const POST: RequestHandler = ({ request }) =>
  handleReport(request, {
    maxBodyBytes: 4 * 1024 * 1024,
    sinks: [toWebhook({ endpoint: process.env.SLACK_WEBHOOK_URL!, format: "slack" })],
  });

The export is named after the method and typed RequestHandler; ./$types gives the same type once svelte-kit sync has run, and $env/dynamic/private is the SvelteKit way to read the same variable as process.env. event.request is the untouched web Request, so signing works. event.getClientAddress() is the address, so pass it as remoteAddress and leave trustProxy alone — the adapter's own proxy configuration has already been applied to it. The client goes in +layout.svelte, inside onMount.

Nuxt #

server/api/feedback.post.ts:

ts
import { defineEventHandler, toWebRequest } from "h3";
import { handleReport, toGithub } from "bugbottle/server";

export default defineEventHandler((event) =>
  handleReport(toWebRequest(event), {
    maxBodyBytes: 4 * 1024 * 1024,
    sinks: [
      toGithub({
        token: process.env.GITHUB_TOKEN!,
        owner: "acme",
        repo: "app",
        labels: ["bug"],
      }),
    ],
  }),
);

The line that matters is toWebRequest(event): Nitro hands you an H3Event rather than a Request, and that turns one into the other. .post.ts in the filename is the method, so there is no if to write. Nuxt auto-imports both functions — the import line is for the type-checker and for whoever reads the file. The raw body survives as long as nothing called readBody(event) first, which consumes the stream signing needs. On the Node preset event.node.req.socket.remoteAddress is the connection, which is what remoteAddress wants; on an edge preset there is none, so use trustProxy. The client goes in a .client.ts plugin under plugins/.

Astro #

src/pages/api/feedback.ts:

ts
import type { APIRoute } from "astro";
import { handleReport, toLinear } from "bugbottle/server";

export const prerender = false;

export const POST: APIRoute = ({ request }) =>
  handleReport(request, {
    maxBodyBytes: 4 * 1024 * 1024,
    sinks: [
      toLinear({ apiKey: process.env.LINEAR_API_KEY!, teamId: process.env.LINEAR_TEAM_ID! }),
    ],
  });

export const prerender = false is the line to remember: in a static build an endpoint without it is run once at build time and there is nothing left to POST to. It needs an adapter, so the route runs on a server. context.request is the web Request as it arrived, so signing works, and context.clientAddress is the address to pass as remoteAddress — the adapter decides what it means, so do not also set trustProxy. The client goes in a <script> in the layout — one island, no framework.

React Router 7 (and Remix) #

app/routes/api.feedback.ts:

ts
import type { ActionFunctionArgs } from "react-router";
import { handleReport, toResend } from "bugbottle/server";

export async function action({ request }: ActionFunctionArgs) {
  return handleReport(request, {
    maxBodyBytes: 4 * 1024 * 1024,
    sinks: [
      toResend({
        apiKey: process.env.RESEND_API_KEY!,
        from: "[email protected]",
        to: "[email protected]",
      }),
    ],
  });
}

It is an action, not a loader: a loader only ever sees a GET. Remix v2 is the same file with the import from @remix-run/node, and React Router's framework mode generates the same shape as Route.ActionArgs in ./+types/api.feedback if you prefer the typed route. Nothing has read the body when action runs, so signing works — leave request.formData() and request.json() alone here. There is no address on ActionFunctionArgs: on @react-router/express reach for req.socket.remoteAddress in the server file, and otherwise set trustProxy. The client goes in the root route's component, or in entry.client.tsx.

Hono #

Anywhere in the app:

ts
import { Hono } from "hono";
import { handleReport, toWebhook } from "bugbottle/server";

const app = new Hono<{ Bindings: { SLACK_WEBHOOK_URL: string } }>();

app.post("/api/feedback", (c) =>
  handleReport(c.req.raw, {
    maxBodyBytes: 4 * 1024 * 1024,
    sinks: [toWebhook({ endpoint: c.env.SLACK_WEBHOOK_URL, format: "slack" })],
  }),
);

export default app;

c.req is Hono's own wrapper; c.req.raw is the web Request underneath it, which is what handleReport wants. Bindings types c.env — on Workers those are the bindings, on Node and Bun read process.env instead. The raw body is untouched, so signing works, provided no middleware in front of this route has already read it. getConnInfo — imported from the adapter for your runtime — gives remote.address for remoteAddress; on Workers there is no socket, so { header: "CF-Connecting-IP" } is the setting instead. The client mounts wherever your HTML is served from; Hono only serves it.

WordPress #

No recipe: the plugin at github.com/mahope/bugbottle-wordpress is the endpoint, the panel and an admin list of what arrived. One activation.

Edit this page on GitHub