Umami setup
Wires a self-hosted (or Umami Cloud) tracker into a web app in three steps:
- Tracker script — emitted in the document
<head>, gated on two env vars. - Core Web Vitals — one
data-performance="true"attribute on the same tag. - Session recorder (optional) — a second
<script>gated on its own env var so it can be toggled without a code change.
Everything is opt-in via env vars. Unset → no scripts injected → zero network calls to Umami. This matters for CI, preview deploys, and environments where the user wants analytics off entirely.
Standard env vars
Use these names across all frameworks. For frameworks that require a prefix (Next.js → NEXT_PUBLIC_, Vite → VITE_), prepend accordingly and document the mapping in .env.example.
| Var | Required? | Example |
|---|---|---|
PUBLIC_UMAMI_SRC |
yes, for tracker | https://umami.example.com/script.js |
PUBLIC_UMAMI_WEBSITE_ID |
yes, for tracker | f5a2a82d-44c3-45b9-9cb6-be327bd78e86 |
PUBLIC_UMAMI_RECORDER_SRC |
no, enables recorder when set | https://umami.example.com/recorder.js |
Critical — URL paths differ by script:
PUBLIC_UMAMI_SRCends in/script.js(the tracker).PUBLIC_UMAMI_RECORDER_SRCends in/recorder.js(the session recorder).Both must be full URLs, not the dashboard root. A common mistake is pasting
https://umami.example.com/(the dashboard) intoPUBLIC_UMAMI_SRC. The browser loads the dashboard's HTML as a script, fails silently, and zero pageviews land. When the user says "I see the request go out but no data shows up in Umami," check this first.
Workflow
- Detect the framework. Read
package.json(look forsvelte,next,nuxt,astro,vite), and scan forindex.html,src/routes/+layout.svelte,app/layout.tsx,pages/_document.tsx, etc. If you can't tell, ask the user. - Open the matching section below and copy the snippet into the root document head of the app.
- Update
.env.examplewith the three vars, empty by default, with a short comment on what each does. Don't commit.env/.env.local. - Tell the user the verification steps (see § Verifying). The single most common failure is step 1's URL gotcha — call it out explicitly.
Keep the change minimal. This skill is meant to be a drop-in — resist the urge to add abstraction layers, feature flags, or helper libraries unless the user asks.
SvelteKit
Put the head snippet in the root layout — src/routes/+layout.svelte, not a nested one — so it covers every route, SSR or SPA.
<script lang="ts">
import { env } from '$env/dynamic/public';
const umamiSrc = env.PUBLIC_UMAMI_SRC;
const umamiWebsiteId = env.PUBLIC_UMAMI_WEBSITE_ID;
const umamiRecorderSrc = env.PUBLIC_UMAMI_RECORDER_SRC;
</script>
<svelte:head>
{#if umamiSrc && umamiWebsiteId}
<script
defer
src={umamiSrc}
data-website-id={umamiWebsiteId}
data-performance="true"
></script>
{/if}
{#if umamiRecorderSrc && umamiWebsiteId}
<script
defer
src={umamiRecorderSrc}
data-website-id={umamiWebsiteId}
data-sample-rate="1"
data-mask-level="moderate"
data-max-duration="300000"
></script>
{/if}
</svelte:head>
- SvelteKit requires the
PUBLIC_prefix for browser-exposed env vars. $env/dynamic/publicis read at runtime — flipping Umami on or off in production is an env change, not a rebuild. If the project standardises on$env/static/public, use that instead (build-time).- Umami v2 auto-tracks SvelteKit client-side navigations via the History API patch. No
afterNavigatehook needed.
Next.js (App Router)
In app/layout.tsx:
import Script from 'next/script';
const UMAMI_SRC = process.env.NEXT_PUBLIC_UMAMI_SRC;
const UMAMI_WEBSITE_ID = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
const UMAMI_RECORDER_SRC = process.env.NEXT_PUBLIC_UMAMI_RECORDER_SRC;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
{UMAMI_SRC && UMAMI_WEBSITE_ID && (
<Script
src={UMAMI_SRC}
data-website-id={UMAMI_WEBSITE_ID}
data-performance="true"
strategy="afterInteractive"
/>
)}
{UMAMI_RECORDER_SRC && UMAMI_WEBSITE_ID && (
<Script
src={UMAMI_RECORDER_SRC}
data-website-id={UMAMI_WEBSITE_ID}
data-sample-rate="1"
data-mask-level="moderate"
data-max-duration="300000"
strategy="afterInteractive"
/>
)}
</body>
</html>
);
}
- Next.js requires the
NEXT_PUBLIC_prefix (notPUBLIC_). Document the mapping in.env.example. - Use
next/scriptwithstrategy="afterInteractive"so it doesn't block first paint. NEXT_PUBLIC_*are inlined at build time. Toggling Umami on or off requires a rebuild, not just an env change.
Next.js (Pages Router)
In pages/_document.tsx:
import { Html, Head, Main, NextScript } from 'next/document';
const UMAMI_SRC = process.env.NEXT_PUBLIC_UMAMI_SRC;
const UMAMI_WEBSITE_ID = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
const UMAMI_RECORDER_SRC = process.env.NEXT_PUBLIC_UMAMI_RECORDER_SRC;
export default function Document() {
return (
<Html>
<Head>
{UMAMI_SRC && UMAMI_WEBSITE_ID && (
<script
defer
src={UMAMI_SRC}
data-website-id={UMAMI_WEBSITE_ID}
data-performance="true"
/>
)}
{UMAMI_RECORDER_SRC && UMAMI_WEBSITE_ID && (
<script
defer
src={UMAMI_RECORDER_SRC}
data-website-id={UMAMI_WEBSITE_ID}
data-sample-rate="1"
data-mask-level="moderate"
data-max-duration="300000"
/>
)}
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
Nuxt 3
In app.vue (or a dedicated plugin), use useHead:
<script setup lang="ts">
const config = useRuntimeConfig();
const src = config.public.umamiSrc as string | undefined;
const websiteId = config.public.umamiWebsiteId as string | undefined;
const recorderSrc = config.public.umamiRecorderSrc as string | undefined;
useHead({
script: [
...(src && websiteId
? [{
defer: true,
src,
'data-website-id': websiteId,
'data-performance': 'true',
}]
: []),
...(recorderSrc && websiteId
? [{
defer: true,
src: recorderSrc,
'data-website-id': websiteId,
'data-sample-rate': '1',
'data-mask-level': 'moderate',
'data-max-duration': '300000',
}]
: []),
],
});
</script>
Expose the vars in nuxt.config.ts:
export default defineNuxtConfig({
runtimeConfig: {
public: {
umamiSrc: '',
umamiWebsiteId: '',
umamiRecorderSrc: '',
},
},
});
Env vars: NUXT_PUBLIC_UMAMI_SRC, NUXT_PUBLIC_UMAMI_WEBSITE_ID, NUXT_PUBLIC_UMAMI_RECORDER_SRC.
Astro
In the shared head component (e.g. src/components/BaseHead.astro or src/layouts/Layout.astro):
---
const UMAMI_SRC = import.meta.env.PUBLIC_UMAMI_SRC;
const UMAMI_WEBSITE_ID = import.meta.env.PUBLIC_UMAMI_WEBSITE_ID;
const UMAMI_RECORDER_SRC = import.meta.env.PUBLIC_UMAMI_RECORDER_SRC;
---
{UMAMI_SRC && UMAMI_WEBSITE_ID && (
<script
is:inline
defer
src={UMAMI_SRC}
data-website-id={UMAMI_WEBSITE_ID}
data-performance="true"
/>
)}
{UMAMI_RECORDER_SRC && UMAMI_WEBSITE_ID && (
<script
is:inline
defer
src={UMAMI_RECORDER_SRC}
data-website-id={UMAMI_WEBSITE_ID}
data-sample-rate="1"
data-mask-level="moderate"
data-max-duration="300000"
/>
)}
is:inline is important — without it, Astro tries to bundle the external script and silently drops the data-* attributes.
Vite + React / Vue (plain Vite, not SvelteKit / Nuxt)
Option A — edit index.html directly with Vite env-var substitution:
<!-- in <head> -->
<script defer
src="%VITE_UMAMI_SRC%"
data-website-id="%VITE_UMAMI_WEBSITE_ID%"
data-performance="true"></script>
Vite replaces %VITE_*% tokens at build time. Downside: if the env var is empty the tag still renders (with empty src) — the browser swallows it, but it's a wasted tag. For a real on/off toggle, prefer Option B.
Option B — inject from app code on mount (React example):
import { useEffect } from 'react';
export function UmamiScripts() {
useEffect(() => {
const src = import.meta.env.VITE_UMAMI_SRC;
const websiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
const recorderSrc = import.meta.env.VITE_UMAMI_RECORDER_SRC;
if (!src || !websiteId) return;
const make = (s: string, extra: Record<string, string> = {}) => {
const el = document.createElement('script');
el.defer = true;
el.src = s;
el.dataset.websiteId = websiteId;
for (const [k, v] of Object.entries(extra)) el.dataset[k] = v;
document.head.appendChild(el);
return el;
};
const tracker = make(src, { performance: 'true' });
const recorder = recorderSrc
? make(recorderSrc, {
sampleRate: '1',
maskLevel: 'moderate',
maxDuration: '300000',
})
: null;
return () => {
tracker.remove();
recorder?.remove();
};
}, []);
return null;
}
Mount <UmamiScripts /> once in the root component.
Plain static HTML
No env-var gating is possible without a build step — just edit <head>:
<script defer src="https://umami.example.com/script.js"
data-website-id="YOUR_WEBSITE_ID"
data-performance="true"></script>
<!-- Optional recorder — delete to disable -->
<script defer src="https://umami.example.com/recorder.js"
data-website-id="YOUR_WEBSITE_ID"
data-sample-rate="1"
data-mask-level="moderate"
data-max-duration="300000"></script>
If the user wants conditional injection without a framework, suggest either (a) picking one of the setups above, or (b) a tiny inline <script> that checks a server-set cookie/meta tag and injects the tracker dynamically.
.env.example template
Append this block:
# Umami analytics — self-hosted or Umami Cloud. Leave PUBLIC_UMAMI_SRC and
# PUBLIC_UMAMI_WEBSITE_ID unset to disable entirely. Must be the full tracker
# URL ending in /script.js, not the dashboard root.
PUBLIC_UMAMI_SRC=""
PUBLIC_UMAMI_WEBSITE_ID=""
# Optional session recorder — set to enable, unset to kill instantly.
PUBLIC_UMAMI_RECORDER_SRC=""
(Swap the prefix per framework: NEXT_PUBLIC_, NUXT_PUBLIC_, VITE_, etc.)
Verifying
After wiring the snippet, walk the user through this — most failures get caught at step 1 or 2.
- Hit
$PUBLIC_UMAMI_SRCin the browser. Should return JavaScript that starts with(function(){...}or similar. If it returns HTML, the URL is wrong (probably the dashboard root). Fix before going further. - Open devtools → Network, load any page of the app:
- GET to
.../script.jsshould be200and served as JS. - POST to
.../api/sendshould fire on initial load, return200. window.umamishould be defined in the console.
- GET to
- Trigger a client-side navigation (SPA link click). A second POST to
/api/sendshould fire. If not, the tracker isn't mounted on every route — move the snippet up to the root layout. - Dashboard check. Events take ~30 s to surface in the Umami UI. If
/api/sendreturns 2xx but events don't appear, thedata-website-idis wrong. - Disable path. Unset the env vars and reload. There should be zero Umami requests in the Network tab. Confirms the gating works for CI / preview deploys.
Gotchas
- Mind the script paths.
PUBLIC_UMAMI_SRCends in/script.js(tracker),PUBLIC_UMAMI_RECORDER_SRCends in/recorder.js(recorder). Pasting the dashboard root into either is the #1 cause of "script loads but no data appears." If the user renamedTRACKER_SCRIPT_NAMEon the Umami server, adjust accordingly. - Performance (Core Web Vitals) needs Umami v3.1.0+. Older instances ignore
data-performance="true"silently — no error, just no vitals data. - The recorder reuses
PUBLIC_UMAMI_WEBSITE_ID— no separate ID. - Adblockers target
/script.jsand/api/sendby default. If bypassing ad-block matters, rename the tracker on the Umami server (TRACKER_SCRIPT_NAME) and self-host under a non-umami.subdomain (stats.example.com). - SPA navigation is auto-tracked in Umami v2 via
history.pushStatepatching. Don't reach forafterNavigate/router.afterEach/useRouter().eventsunless verification step 3 actually fails. - Put the script in the root layout, not a per-page head. Putting it in one page means route changes away from that page lose tracking.
- Don't commit
.env/.env.local. Only commit.env.examplewith empty values. - Respect Do Not Track. Umami does by default; if the user wants to override, that's
data-do-not-track="false"on the tracker tag — but check the user actually wants this.
When the user reports "no data in Umami"
Run this triage in order — stop at the first failure:
- URL gotcha. Open
$PUBLIC_UMAMI_SRCin a browser. HTML response → wrong URL. Must end in/script.js. - Env vars actually set in the running environment. In Next.js remember
NEXT_PUBLIC_*, notPUBLIC_*. Check Vercel/Dokploy/wherever the app is deployed. - Script tag actually in the rendered HTML.
curl <app-url> | grep umami. If missing, the conditional isn't firing — check env var values. - Umami server reachable from the browser's network. Devtools Network: is
/api/sendreturning200?4xx→ website-id mismatch.0/ blocked → adblocker or CORS. - Umami version. For performance data specifically, confirm the server is v3.1.0+.