Integrations
Next.js
Next.js owns the error boundary in an App Router application, so
createRootErrorHandlers is not the way in here — Next.js calls createRoot
itself, and the handlers it installs are the ones that decide what a person
sees. What bugbottle adds is the other half: Next.js tells you that a segment
broke, and only your endpoint can turn that into a report somebody can act on.
Three places, in the order an error reaches them.
1. error.tsx, for a route segment. This is the fallback the person is
looking at when it throws, which makes it the one place in a Next.js
application where a "tell us what happened" button is guaranteed to be in front
of somebody who has just seen something go wrong:
// app/checkout/error.tsx
"use client";
import { useState } from "react";
import { buildReport, sendReport } from "bugbottle";
export default function CheckoutError({
error,
retry,
}: {
error: Error & { digest?: string };
retry: () => void;
}) {
const [sending, setSending] = useState(false);
return (
<div role="alert">
<h2>Checkout stopped working.</h2>
<p>{error.message}</p>
<button onClick={retry}>Try again</button>
<button
disabled={sending}
onClick={async () => {
setSending(true);
try {
// `buildReport` collects on the machine it runs on, so this has to
// be a client component — which error.tsx already is.
const { id } = await sendReport(
"/api/feedback",
buildReport({ type: "error", message: error.message }),
);
console.info("bugbottle filed", id);
} finally {
setSending(false);
}
}}
>
{sending ? "Sending…" : "Report this"}
</button>
</div>
);
}Two things about that snippet are worth reading twice. It sends nothing
until the button is pressed: a report is a message from a person, and sending
one on their behalf is telemetry, which this library is not. And error.digest
belongs in the message when there is one — in production a Server Component
error reaches the browser with its message replaced by a generic one, and
digest is the only thing that ties the report to the line in your server log
that still has the real message. Pass it on:
buildReport({
type: "error",
message: error.digest ? `${error.message} (digest ${error.digest})` : error.message,
});2. global-error.tsx, for the root layout. Same two props, but it replaces
the whole document, so it has to render its own <html> and <body> and it
does not get your global styles. Keep it short — a form with a screenshot
uploader is more than this page has room for, and the panel is a better answer
there:
// app/global-error.tsx
"use client";
import { buildReport, sendReport } from "bugbottle";
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
return (
<html>
<body>
<h1>Something went wrong.</h1>
<button
onClick={() =>
sendReport("/api/feedback", buildReport({ type: "error", message: error.message }))
}
>
Report this
</button>
</body>
</html>
);
}3. The report form, where the rest of the bugs come from. Most reports are not render errors — they are the save button that did nothing. That path is the React hook, and it wants a client component like any other:
// app/components/ReportButton.tsx
"use client";
import { useBugReport } from "bugbottle/react";
export function ReportButton() {
const report = useBugReport({ endpoint: "/api/feedback" });
return <button onClick={report.open}>Report a problem</button>;
}For an application that does not bundle, "One script tag" is the whole
integration, and the placement is the same as any other page: the tag goes in
the root layout's body, and it needs no "use client" because a plain
<script> is not a component. That also makes it the only answer on
global-error.tsx, which is a separate page load without the layout.
Receiving it. A route handler is a Request in and a Response out, and
handleReport is the one that answers:
// app/api/feedback/route.ts
import { handleReport, fileStore, slackSink } from "bugbottle/server";
const store = fileStore({ dir: "./reports" }).store;
const notify = slackSink({ webhookUrl: process.env.SLACK_WEBHOOK_URL! });
export async function POST(request: Request) {
return handleReport(request, { store, sinks: [notify] });
}"Recipes" has the same route in Hono, Cloudflare Workers, Bun and
Deno; nothing about handleReport is Next-specific.
What to check before you ship it. Throw on purpose — throw new Error("boom")
in a Server Component, and something that throws on click — and open a report
from the fallback. Three things should be true: the report arrives with the
route, the viewport and the recent console errors on it; a Server Component
error arrives carrying the digest; and pressing "Try again" after the throw
site is fixed clears the boundary. A boundary that never reports is worse than
no boundary, because it looks like it works.