Vue & Nuxt Error Tracking: Capture Hydration Errors and SSR Crashes
Catch Vue hydration mismatches and SSR crashes before your users report them.
The Slack message came in at 2:47 AM: "Checkout is broken, getting a white screen." I pulled up the production site on my phone, clicked through to cart, and watched the page flash and die. No error in the network tab. No console output in production. Just... nothing.
Turned out a computed property was referencing a ref that didn't exist on the server. Classic hydration mismatch. Vue's client-side code expected DOM structure A, the server had rendered structure B, and the reconciliation silently failed. I spent forty minutes reading through git blame before I found it.
The fix was two lines. Finding the fix was the hard part.
This is a tutorial for wiring error tracking into Vue 3 and Nuxt 3 apps — the kind that catches hydration mismatches, SSR exceptions, and client-side runtime errors before your users do. We're using JustAnalytics because it bundles error tracking with all-in-one observability in one under-5KB script, but the Vue integration patterns work for Sentry or any similar tool with minor tweaks.
What we're building
By the end of this, you'll have:
- Client-side error tracking that catches runtime exceptions and unhandled promise rejections
- SSR error handling that captures server-side crashes in Nuxt's Nitro layer
- Hydration mismatch detection with enough context to actually debug
- Source map uploads so your stack traces show real file names and line numbers
The whole setup is maybe sixty lines of code spread across three files. Takes about twenty minutes if you're starting from scratch.
Prerequisites
- Vue 3.4+ or Nuxt 3.10+ (the patterns differ slightly, I'll cover both)
- Node.js 18+
- A JustAnalytics account (free tier works — 100K events/month)
- Basic familiarity with Vue's Composition API
If you're on Nuxt 2 or Vue 2, the concepts translate but the code samples won't work directly. The Vue 2 error handler signature is different, and Nuxt 2's server middleware lives in a completely different place. I tried to make this guide work for both versions and gave up after an hour. Pick your battles.
Step 1: Install the SDK and add the script
For a plain Vue 3 app (Vite-based):
npm install @justanalytics/vue@^2.3.0
Then in your main.ts:
// src/main.ts
import { createApp } from "vue";
import { JustAnalyticsPlugin } from "@justanalytics/vue";
import App from "./App.vue";
const app = createApp(App);
app.use(JustAnalyticsPlugin, {
siteId: import.meta.env.VITE_JA_SITE_ID,
trackErrors: true,
trackPageviews: true,
});
app.mount("#app");
For Nuxt 3, the setup is a plugin:
// plugins/justanalytics.client.ts
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig();
// Load the script
useHead({
script: [
{
src: "https://cdn.justanalytics.app/script.js",
"data-site": config.public.jaSiteId,
"data-track-errors": "true",
defer: true,
},
],
});
// Hook into Vue's error handler
nuxtApp.vueApp.config.errorHandler = (error, instance, info) => {
console.error(error); // Keep local logging
window.ja?.("error", {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
component: instance?.$options?.name || "Unknown",
info,
});
};
});
The .client.ts suffix matters — it tells Nuxt to only run this plugin in the browser. We'll add server-side tracking separately.
Add your site ID to the environment:
# .env
VITE_JA_SITE_ID=your-site-id # Vue/Vite
NUXT_PUBLIC_JA_SITE_ID=your-site-id # Nuxt
Quick sanity check: throw a deliberate error in a component, load the page, and verify it shows up in your JustAnalytics dashboard within a few seconds. If it doesn't, you probably forgot the environment variable or the site ID is wrong. (I've done both. More than once.)
Step 2: Capture SSR errors in Nuxt
The client plugin catches browser-side errors. But Nuxt apps run code on the server too — API routes, server middleware, asyncData calls. Those exceptions need a different hook.
Create a server plugin:
// server/plugins/error-tracking.ts
import { H3Error } from "h3";
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook("error", async (error, { event }) => {
// Skip 404s and client errors
if (error instanceof H3Error && error.statusCode < 500) {
return;
}
const config = useRuntimeConfig();
await $fetch("https://api.justanalytics.app/v1/errors", {
method: "POST",
headers: {
Authorization: `Bearer ${config.jaApiKey}`,
"Content-Type": "application/json",
},
body: {
site_id: config.public.jaSiteId,
message: error.message,
stack: error.stack,
url: event?.path,
context: "server",
timestamp: new Date().toISOString(),
},
}).catch((e) => {
// Don't let error tracking errors crash the app
console.error("Failed to report error:", e);
});
});
});
This hooks into Nitro's error lifecycle. Every unhandled exception in a server route, middleware, or plugin gets captured. The event?.path gives you the URL that triggered it — useful when you've got ten different API routes and need to know which one is throwing.
Add the API key to your runtime config:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
jaApiKey: process.env.JA_API_KEY, // Server-only
public: {
jaSiteId: process.env.NUXT_PUBLIC_JA_SITE_ID,
},
},
});
Server-side keys stay in runtimeConfig (not runtimeConfig.public) so they're never exposed to the client bundle. Nuxt handles this automatically, but I've seen people put secrets in the wrong place and ship them to browsers. Check your built output if you're paranoid. (If you're building a SaaS with sensitive API keys, tools like DevOS help manage environment configs across environments.)
Step 3: Detect hydration mismatches
Hydration mismatches are Vue's way of saying "the server rendered this, but the client expected something different." They usually don't crash the page — they just cause subtle bugs that are nearly impossible to reproduce.
Vue 3.4+ exposes a __VUE_PROD_HYDRATION_MISMATCH_DETAILS__ flag. Enable it in your Vite config:
// vite.config.ts
export default defineConfig({
define: {
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: true,
},
});
Or in Nuxt:
// nuxt.config.ts
export default defineNuxtConfig({
vite: {
define: {
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: true,
},
},
});
This makes hydration warnings include actual details in production instead of "enable VUE_PROD_HYDRATION_MISMATCH_DETAILS to see more." The downside: slightly larger bundle. Worth it for debugging.
Now wire up a watcher:
// plugins/hydration-tracking.client.ts
export default defineNuxtPlugin(() => {
if (typeof window === "undefined") return;
const originalWarn = console.warn;
console.warn = (...args) => {
const message = args[0];
if (
typeof message === "string" &&
message.includes("Hydration") &&
message.includes("mismatch")
) {
window.ja?.("error", {
type: "hydration_mismatch",
message: args.join(" "),
url: window.location.href,
severity: "warning",
});
}
originalWarn.apply(console, args);
};
});
Is monkey-patching console.warn gross? Yes. Does Vue give us a better hook? No. Does it work? Also yes.
Look, I'm not proud of this solution. It feels hacky because it is hacky. I'd love a proper onHydrationMismatch callback, but until Vue ships one, this catches the warnings reliably.
Step 4: Upload source maps
Production JavaScript is minified. Without source maps, your stack traces look like main.a7f3c2.js:1:47832 — technically accurate, completely useless.
For Vite, install the plugin:
npm install @justanalytics/vite-plugin@^1.2.0 --save-dev
// vite.config.ts
import { justAnalyticsSourceMaps } from "@justanalytics/vite-plugin";
export default defineConfig({
plugins: [
vue(),
justAnalyticsSourceMaps({
apiKey: process.env.JA_API_KEY,
siteId: process.env.VITE_JA_SITE_ID,
}),
],
build: {
sourcemap: true, // Required
},
});
For Nuxt, it's similar:
// nuxt.config.ts
export default defineNuxtConfig({
sourcemap: {
client: true,
server: true,
},
vite: {
plugins: [
justAnalyticsSourceMaps({
apiKey: process.env.JA_API_KEY,
siteId: process.env.NUXT_PUBLIC_JA_SITE_ID,
}),
],
},
});
The plugin uploads maps at build time and then deletes them from your output — you don't want source maps deployed to production where anyone can download them and read your code. If you're using a CI/CD pipeline, make sure JA_API_KEY is set as a secret.
One gotcha: source maps add time to your build. For a medium-sized Nuxt app, maybe 20-30 seconds. Not a big deal for production deploys, annoying if you're running builds locally constantly like I do when I'm debugging CSS (yes, I know hot reload exists, no, it doesn't always catch what I'm doing). Consider only enabling them for CI:
sourcemap: {
client: process.env.CI === "true",
server: process.env.CI === "true",
},
Common errors and how to fix them
"window.ja is not a function"
The script hasn't loaded yet. Either you're calling it synchronously during setup (don't), or there's a network issue blocking the CDN. Wrap calls in a guard: window.ja?.("error", ...) — the optional chaining handles the race condition gracefully.
Errors appear but stack traces are garbled
Source maps aren't uploading, or the build's source-map-to-code mapping is broken. Check that sourcemap: true is actually in your config and that the plugin's API key is valid. The dashboard shows a source map status indicator — if it says "missing," your upload didn't work.
Server errors show but have no stack trace
Nitro's production mode strips stack traces from responses by default (security thing — you don't want users seeing your file paths). But the error.stack property should still exist on the original Error object. If it's undefined, you're probably catching an already-sanitized H3Error. Wrap your route handlers in try/catch and throw a fresh Error with the original message.
Hydration warnings don't appear in error tracking
You forgot the __VUE_PROD_HYDRATION_MISMATCH_DETAILS__ flag, or the console.warn patch isn't running. Verify by throwing a deliberate mismatch: render something on the server that reads Date.now() and watch the console.
Too many errors flooding the dashboard
You're probably catching the same error in a loop. Common with reactive watchers that throw, trigger a re-render, throw again, trigger another re-render... Add a debounce to your error handler, or fix the underlying watcher. The error tracking is working correctly — it's your code that's broken. This is where APM and distributed tracing helps you trace the exact sequence of calls.
Honestly, this happened to me last month. I blamed JustAnalytics support for "duplicate errors" before realizing I had a watcher calling an API that 500'd, which triggered the watcher again. Embarrassing.
Next steps
The setup above gets you error visibility. To make it useful, a few things to wire up next:
Connect error tracking to session replay. JustAnalytics links errors to replay recordings automatically — you can watch exactly what the user did before the crash. That checkout bug I mentioned at the start? Would've taken three minutes to diagnose if I'd had the replay. The session replay for SaaS onboarding flows post covers the integration.
Set up alerts. Errors in the dashboard are nice; errors in Slack are actionable. The alert rules let you filter by error type, URL pattern, or frequency — so you get pinged for "new error affecting 100+ users" but not "that one guy with a weird browser extension." Teams running ad campaigns often pair this with ClickzProtect to correlate client errors with suspicious click patterns.
Add release tracking. Tag your errors with a release version so you know when a deploy introduced a regression. Pass release: process.env.GIT_COMMIT_SHA in your error context and the dashboard groups errors by deploy. This alone has saved me hours of "wait, when did this start?" detective work. The uptime monitoring and SLO tracking features tie directly into release tracking to spot regressions.
The correlate errors with analytics funnel drop-off guide walks through connecting error spikes to specific conversion steps — useful when you're trying to figure out why signups dropped 30% on Tuesday.
Frequently Asked Questions
Does JustAnalytics replace Sentry for Vue apps?
It covers the same error tracking surface — stack traces, source maps, issue grouping, rage-click detection — plus analytics, APM, session replay, uptime, and logs in one script. If you're already paying for Sentry and a separate analytics tool, consolidating saves money. If you need Sentry's deep integrations with specific issue trackers or their performance monitoring at massive scale, that's where they still pull ahead.
Will this catch errors in Nuxt server routes?
Yes. The server-side integration hooks into Nitro's error handling, so unhandled exceptions in /server/api routes, middleware, and plugins all get captured with full stack traces. You'll see them tagged as server-side in the dashboard, separate from client errors.
Do I need to upload source maps?
For readable stack traces in production, yes. The minified line numbers are useless otherwise. Both Vite and Nuxt have plugins that upload maps at build time — the setup takes about ten minutes and you only do it once. Without source maps, you'll see errors but won't know where they came from.
How does pricing compare to Sentry for a small team?
Sentry's Team plan starts at $26/month for 50K errors. JustAnalytics Pro is $49/month ($39 annual) for 1M events — and that quota covers errors, pageviews, APM spans, and replay sessions combined. For most small teams, the combined cost of Sentry plus a separate analytics tool exceeds what you'd pay for everything in one place.
Try JustAnalytics
All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).
Author at JustAnalytics.