Integrations
Nuxt
Nuxt splits the error path in two halves that most integrations wire up
backwards. The first is vueApp.config.errorHandler, and it is the bigger of
the two: Nuxt's own words are that it "will receive all Vue errors, even if they
are handled", where the vue:error hook that most guides reach for is built on
onErrorCaptured and therefore only fires for errors that reached the top. The
second half is app:error, which covers the window before Vue exists at all —
plugins, app:created, app:beforeMount, the mount itself, app:mounted. A
plugin that reports through vue:error alone reports every crash and none of
the failures, and the failures are the ones that cost a session.
One plugin, both halves. It must be a .client.ts plugin, and it must be
enforce: 'pre' — both halves of that sentence are load-bearing, and the second
is explained under "The plugin that never runs" below.
// plugins/bugbottle.client.ts
import { mountBugbottle, type BugbottleWidget } from "bugbottle/ui";
import { htmlToImage } from "bugbottle/html-to-image";
import { da } from "bugbottle/locales";
export default defineNuxtPlugin({
name: "bugbottle",
// Without this a plugin that throws stops the ones after it, and this is one
// of the ones after it. Read "The plugin that never runs" before removing it.
enforce: "pre",
setup(nuxtApp) {
const config = useRuntimeConfig();
let widget: BugbottleWidget | undefined;
let wanted = false;
// The window-level half: the message is already known, so the box is not empty.
const open = (message?: string) => {
if (widget) {
widget.open({ message });
} else {
// An error before this plugin's own `setup` finished still wants a panel.
wanted = true;
}
};
// Report each error once. `vue:error` and `config.errorHandler` are handed
// the same object for the same incident; this is what keeps that to one
// report. A thrown string has no identity to remember, so it is reported
// every time — which is the honest answer for a value nobody can compare.
const reported = new WeakSet<object>();
const report = (error: unknown, where: string): void => {
if (typeof error === "object" && error !== null) {
if (reported.has(error)) return;
reported.add(error);
}
// The text goes in the box as well as the console: a reporter watching a
// broken page writes about the error they can see, not one they retype.
open(error instanceof Error ? error.message : String(error));
console.error(`[bugbottle:${where}]`, error);
};
// Everything Vue knows about, including errors a component caught itself.
// Nuxt unsets *its own* default handler once the app hydrates, and only
// that one — a handler set here is still installed afterwards.
nuxtApp.vueApp.config.errorHandler = (error, instance, info) => {
report(error, `vue:${info}`);
// Keep the console. Replacing Vue's handler silently drops it otherwise,
// and this library is not a monitoring agent.
console.error(`[vue:${info}]`, error, instance);
};
// `vue:error` is not redundant with the handler above. Nuxt calls it for
// every error that reaches the root, and `<NuxtErrorBoundary>` calls it
// itself and then swallows the error — so an error inside a boundary never
// reaches `config.errorHandler` at all. Wire both, report each once.
nuxtApp.hook("vue:error", (error) => report(error, "vue:error"));
// Everything that happens before Vue exists: a plugin that throws, the
// mount, `app:mounted`. This half arrives in production and in dev alike.
nuxtApp.hook("app:error", (error) => report(error, "app:error"));
// The one error Nuxt handles on purpose and never shows anybody: a hashed
// chunk 404s because a deploy replaced it, and Nuxt's answer is a hard
// reload. Read "The error that reloads itself away" below.
nuxtApp.hook("app:chunkError", ({ error }) => report(error, "chunkError"));
// `onNuxtReady` is the client-only, post-hydration hook. `.client.ts` already
// keeps the panel off the server, and this keeps it off the first paint.
onNuxtReady(() => {
widget = mountBugbottle({
endpoint: config.public.feedbackEndpoint as string,
screenshot: htmlToImage,
locale: da,
extra: { appVersion: config.public.appVersion },
openOnError: { prefill: true },
});
if (wanted) widget.open();
});
},
});The two Vue hooks overlap — Nuxt's root onErrorCaptured hands the same error
to vue:error and then returns undefined, so Vue calls config.errorHandler
with it too — which is what report is for. app:error is the one that can
still open the panel twice for a single incident, because showError wraps the
error in a fresh NuxtError before it calls the hook; open() is idempotent, so
the cost is a duplicated console line rather than two panels.
There is no teardown, and that is not an oversight: a .client.ts plugin runs
once per page load, and every listener mountBugbottle installs dies with the
document. An application that tears its Nuxt app down without leaving the page
is a test or experimental.componentIslands, and both are better served by
mounting inside the component that owns the lifetime.
isNuxtError is the import worth keeping when a createError should become a
report rather than a string. Nuxt's NuxtError is not a class you can
instanceof — it is an interface extending H3Error with status and
statusText (and the deprecated statusCode and statusMessage) on top — so
the guard is a function, and the type is what tells TypeScript
error.statusCode is a number rather than an Error.
// A throw like this is a report with a status on it, not an unhandled Error.
throw createError({
status: 402,
statusText: "Payment Required",
message: `Plan ${planId} is not active`,
data: { planId },
});Note the split: statusText for the short HTTP phrase, message for anything a
person reads. Nuxt's statusText is restricted to tabs, spaces and visible
ASCII, and a message is what reaches your error.vue.
The plugin that never runs #
This is the part that decides whether the integration works, and it is one line
of Nuxt's source. In applyPlugins, a plugin that throws is rethrown straight
away unless payload.error is already set:
try {
await applyPlugin(nuxtApp, plugin);
} catch (e) {
// short circuit if we are not rendering `error.vue`
if (!nuxtApp.payload.error) { throw e }
error ||= e as Error;
}The loop stops. Every plugin after the one that threw never runs — including
yours, if it is registered later than a plugin that fails. And when that happens
you get error.vue, which is the failure mode that hides everything: a full
page with a status number on it and no console line, on a page nobody can report
from, because the panel is one of the plugins that did not load.
enforce: "pre" sorts yours to the front, ahead of every default plugin. Two
things do not help: enforce: "post" is worse, and a dependsOn name is only
honoured between two plugins that both exist. The other half of the answer is
that an error thrown inside error.vue reaches nothing at all, so keep that
file to the one button it needs.
The related trap is the one Nuxt's own docs warn about, and it is why a
throwing plugin is worth reporting rather than reloading: $route and
useRouter are not ready until plugins have run, so a plugin that threw "won't
be re-run until you clear the error", and a report gathered from the error page
has no route in it.
The error that reloads itself away #
Nuxt has a third hook that its error-handling page never mentions:
app:chunkError, typed ({ error }: { error: any }) => HookResult. Three
built-in plugins subscribe to it — nuxt:chunk-reload,
nuxt:chunk-reload-immediate and nuxt:chunk-reload-crawler — and all they do
is call reloadNuxtApp, so the page hard-reloads and the evidence is gone. That
is a good default for a visitor and a silent one for you: the trigger is a hashed
chunk URL that no longer exists because a deploy went out, which is precisely
the class of bug a person reports and you cannot see.
Set experimental.emitRouteChunkError: "manual" to take it over, and report
from the hook. The type in @nuxt/schema is
false | "manual" | "automatic" | "automatic-immediate" — four values, where
the documentation names two. With "manual" the built-in plugins still run
because the event is still emitted, so the reload is a safety net under your
report rather than instead of it.
// nuxt.config.ts
export default defineNuxtConfig({
experimental: { emitRouteChunkError: "manual" },
});On the error page itself #
error.vue is a separate page load, which means route middleware runs again
and useError() is the way to ask "am I looking at an error page?" from a
middleware. It is also the one place a report can be about the failure rather
than of it, because the panel is not mounted there. One button is the whole
integration:
<!-- error.vue -->
<script setup lang="ts">
import type { NuxtError } from "#app";
const props = defineProps({ error: Object as () => NuxtError });
</script>
<template>
<main>
<h1>{{ props.error.statusCode }}</h1>
<p>{{ props.error.message }}</p>
<NuxtLink to="/">Back to safety</NuxtLink>
<ReportProblem :message="`${props.error.statusCode} on the error page`" />
</main>
</template>statusCode and statusMessage are the deprecated spellings on NuxtError;
status and statusText are the current ones, and a NuxtError built by hand
only has the latter. The panel is not mounted on this page — it is a fresh
document after a fatal error, and the plugin that mounts it is a plugin like any
other — so a report from here is <ReportProblem>, defined below.
For a local boundary instead of the whole page, <NuxtErrorBoundary> renders
its #error slot in place and returns false from its own onErrorCaptured, so
the error stops there: it never reaches the error page, and it never reaches
config.errorHandler. It calls vue:error itself on the way past, which is why
the plugin wires both hooks — and why a page that only wired the handler misses
everything inside a boundary.
<NuxtErrorBoundary @error="onError">
<SomePanel />
<template #error="{ error, clearError }">
<p>{{ error.message }}</p>
<button type="button" @click="clearError()">Try again</button>
</template>
</NuxtErrorBoundary>The form in your own markup #
The composable, for an application that wants a form rather than a panel. It is
the same state machine as The form (Vue), and inside a plugin it needs an
effect scope, so call it from a component:
<!-- components/ReportProblem.vue -->
<script setup lang="ts">
import { buildReport, captureScreenshot, sendReport } from "bugbottle";
import { htmlToImage } from "bugbottle/html-to-image";
const props = defineProps<{ message?: string }>();
const busy = ref(false);
async function report(): Promise<void> {
busy.value = true;
try {
const config = useRuntimeConfig();
// Screenshots fail open: a render that throws costs the picture, not the report.
const screenshot = await captureScreenshot(htmlToImage).catch(() => null);
await sendReport(
config.public.feedbackEndpoint as string,
buildReport({ type: "bug", message: props.message ?? "Something broke", screenshotDataUrl: screenshot }),
);
} finally {
busy.value = false;
}
}
</script>
<template>
<button type="button" :disabled="busy" @click="report">
{{ busy ? "Sending…" : "Report a problem" }}
</button>
</template>Errors that reach no hook at all. Nuxt's data composables do not throw.
useFetch and useAsyncData put a failed request in error.value and carry on
rendering, so a page whose only wiring is the plugin above reports every crash
and stays quiet about a list that came back empty — which is most of what people
write in. That half is a button, and the panel's floating trigger is already
one.
<script setup lang="ts">
const { data, error } = await useFetch("/api/orders");
if (error.value) {
// Nobody is going to read this, and the panel will never open on its own.
throw createError({ status: 502, message: "Orders could not be loaded" });
}
</script>Throwing it yourself puts it on the framework path, where the plugin sees it. Or
render a message and put a ReportProblem button under it, which is the
version that reaches a person who is not an engineer.
One script tag. For an application that does not bundle, One script tag is
the whole integration, and a Nuxt app has two places to put it and no ordering
to get wrong: app.vue behind ClientOnly, or nuxt.config.ts's
app.head.script with tagPosition: "bodyClose", so it lands after the app's
own markup:
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
script: [
{
src: "https://cdn.jsdelivr.net/npm/bugbottle@1.0.1/dist/bugbottle.js",
"data-endpoint": "/api/feedback",
"data-open-on-error": "prefill",
tagPosition: "bodyClose",
},
],
},
},
});Receiving it. server/api/feedback.post.ts is the endpoint, and Recipes
has the whole snippet — the line that matters there is toWebRequest(event),
because Nitro hands you an H3Event and not a Request. Two Nuxt-specific notes
the snippet does not need to repeat: maxBodyBytes is a cap you have to raise
yourself, since Nitro's own body limit is applied before your handler sees
anything, and the client address is event.node.req.socket.remoteAddress on the
Node preset, or nothing at all on an edge preset, where trustProxy: true is the
answer.
What to check before you ship it. Four throws, and a report from each. A
template that throws, and a click handler that throws, should both open the
panel — that is config.errorHandler, and it is the path that works in dev and
silently does nothing behind a production build if you got the name wrong. Then
throw from a setup() in a plugin registered after the one above: nothing
should happen, because enforce: "pre" is why the report is still there, and
this is the throw that proves it. Third, createError({ fatal: true }) from a
click handler: the page is replaced, so confirm the report went out before it
went. Fourth, deploy over a running tab and navigate — a chunk 404 is invisible
unless emitRouteChunkError: "manual" is set, and that is the one to check on a
phone, on a real network, with an old tab open.