Nuxt Analytics: SSR, Hydration, and Route Tracking Done Right
The Nuxt analytics setup that works across SSR and client navigation, with app.head configuration and events from composables.
Nuxt complicates analytics in exactly one way: your code runs on the server first. A tracker referenced carelessly inside setup() throws during SSR ('window is not defined'), and a script added per-page double-loads. Put the tag in the right place once and both problems vanish — here is that place, plus the Nuxt-specific patterns for events and identity.
Installation via nuxt.config
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
script: [
{
src: 'https://clycyo.com/tracker.js',
defer: true,
'data-tracking-id': 'YOUR_TRACKING_ID',
},
],
},
},
});This renders the tag into the initial HTML — loaded once, before hydration, never duplicated by client navigation. Both rendering modes work: with SSR, the first pageview fires on load with full navigation timing; with ssr: false, it behaves like any SPA. Subsequent vue-router navigations are captured automatically through History API instrumentation — no router hooks, no plugin.
Events: client-side only, by construction
// composables/useAnalytics.ts
export function useAnalytics() {
const track = (name: string, props?: Record<string, unknown>) => {
if (import.meta.client) {
window.webanalytics?.track(name, props);
}
};
return { track };
}The import.meta.client guard makes the composable safe to call from anywhere — components, plugins, middleware — without SSR crashes. Use it for the events that matter (signup, activation, plan clicks) and let automatic capture handle the rest.
Identity and revenue
// After login (client side)
window.webanalytics?.identify(user.email);
await $fetch('/api/me', {
method: 'PATCH',
body: { clycyo_visitor_id: window.webanalytics?.getVisitorId() },
});Persisting the visitor ID lets your Nitro server routes post revenue events from billing webhooks — server-to-server, blocker-proof, joined to the visitor's first-touch UTM.
Nuxt-specific notes
- useHead per-page is unnecessary for the tracker — app.head loads it globally. Reserve per-page head work for metadata.
- Hybrid rendering (routeRules) is fine: prerendered, ISR, and SSR pages all carry the same tag.
- Watch INP, not just LCP: Nuxt's hydration cost shows up in interaction latency; the tracker reports field INP per real visitor, which Lighthouse cannot.
One config block, one composable, two identity lines — that is the entire integration. Snippets for every framework live in the quickstart.