Add Mo tracking to your website in minutes.
Add this single line to your HTML, just before the closing </body> tag:
<script src="https://usemo.app/mo.js" mo-data-key="YOUR_API_KEY"></script>
Replace YOUR_API_KEY with your project's API key from the Projects page.
| Attribute | Required | Description |
|---|---|---|
| mo-data-key | Yes | Your project API key |
| mo-data-endpoint | No | Custom API endpoint (defaults to https://usemo.app/api/v1/track) |
| mo-data-debug | No | Enable console logging for debugging |
| mo-data-require-consent | UK/EU | Track nothing until mo.grantConsent() is called. See Consent below. |
| mo-data-exclude | No | Comma-separated path patterns to never track, e.g. "/admin/*, /preview/*". Declared on the tag, so the script can load with defer instead of blocking rendering the way an inline mo.exclude() call requires. |
| mo-data-error-class | No | Comma-separated classes your site marks a field's error with, e.g. "has-error, is-invalid", read alongside aria-invalid="true". See Form submissions below. |
| mo-data-include | No | Comma-separated allowlist — when set, only matching paths are tracked. |
With filters declared as attributes, add defer to the script tag — the tracker no longer needs to execute before your page renders.
Yes — and it is meant to be there. A tracking key has to be readable by every browser that loads your site, so it is not a secret and we do not treat it as one. What protects your project is the allowed URLs list, not the key.
Every tracking request must arrive from a domain you have authorised. Browsers set the origin of a request themselves and a web page cannot forge it, so if someone copies your key onto their own site, their data is refused. A project with no allowed URLs accepts nothing at all.
How matching works
example.com — the domain, its www form, and its subdomains*.example.com — the same, written explicitlyevilexample.com are refusedhttp://localhost:3000 covers any local portFor completeness: origin checks are enforced by the browser, so they stop other websites using your key. They cannot by themselves stop a script on someone's own machine sending requests with a forged origin — no analytics product can. Rate limiting and per-project session ownership limit what that could achieve, and if a key is ever misused you can delete it and issue a new one from your dashboard at any time.
If your visitors are in the UK or EU, you almost certainly need consent before loading Mo.
Mo stores a small event buffer in the browser's LocalStorage, plus a first-seen marker that tells it whether this browser has visited before — written only once consent is granted and removed when it is withdrawn. Under PECR (UK) and the ePrivacy Directive (EU), storing or reading information on a visitor's device requires consent unless it is strictly necessary to deliver the service they asked for. Analytics is not strictly necessary, so it needs consent — the same rule that applies to analytics cookies. This is your obligation as the site owner; Mo gives you the controls to meet it.
Add mo-data-require-consent and Mo will read and write nothing at all until you grant it. Wire the calls to your existing consent banner:
<script src="https://usemo.app/mo.js" mo-data-key="YOUR_API_KEY" mo-data-require-consent ></script> // when the visitor accepts analytics mo.grantConsent() // when they decline or withdraw — also discards anything buffered mo.revokeConsent() // current state mo.hasConsent()
The choice is remembered across pages, so you only need to call these when it changes. Tracking begins from the moment consent is granted — nothing before it is recorded.
Debug mode logs every decision Mo makes to the browser console with a [Mo] prefix: the key it found, each event recorded, skipped or deduplicated, and every send. Three ways to turn it on; any one is enough.
Works on a live site without touching its code. The value true is accepted too.
https://www.example.com/contact?mo_debug=1Set it once in the console and it stays on for every page of that site in that browser. It follows you through client-side navigation, which the address parameter does not.
localStorage.setItem('mo_debug', '1') // on localStorage.removeItem('mo_debug') // off
Leave it off in production: it is noisy, and it is the only one of the three a visitor would see.
<script src="https://usemo.app/mo.js" mo-data-key="YOUR_API_KEY" mo-data-debug ></script>
Mo exposes a global mo object for manual tracking:
// Track a custom event mo.record('signup_started', { plan: 'pro', source: 'landing_page' });
// Associate session with a user ID mo.identify('user_12345');
// After your form has been sent successfully window.mo?.complete?.();
Only needed for some forms — see Form submissions.
// Immediately send buffered events mo.flush();
// Send what is buffered, then begin a new session mo.newSession();
For a kiosk or a demo's Start button. The only automatic rule is 30 minutes of inactivity; newSession() does nothing until consent is granted.
// Re-fetch the rules, e.g. after your page renders late mo.nudges.refresh();
Rules are fetched once per page load and kept by the browser for a minute; this re-fetches at once. See Nudges.
// Hide the card for the rest of this visit mo.nudges.dismiss('start-hour');
Mo automatically captures the following without any additional code:
Every page load and SPA navigation (supports history API)
Clicks on buttons, links, inputs, and elements with mo-data-track
Input changes on form fields — typed values are never captured; for dropdowns, radios and checkboxes, which option was chosen
Submit attempts, validation errors and successful submissions — see Form submissions
Unique session IDs, timestamps, and user journey steps
When a visit starts, Mo records where it came from, so you can see which sources bring visitors who finish:
utm_source, utm_medium, utm_campaign, utm_term and utm_content from the landing URLgclid, msclkid, fbclid…) — the network's name only, never the ID. A Google or Microsoft ad click counts as Paid even with no UTM tags With mo-data-require-consent set, none of this is read or stored until consent is granted.
The first-seen marker belongs to one site origin, as local storage does: www.example.com, example.com and shop.example.com each keep their own, so a visitor moving between them is counted as new on each.
Mo records every attempt to submit a form, every field the browser rejects, and every successful submission. You can then make "Submitted the form on it" your goal on the Goals page, so a conversion means a form was actually sent rather than a page merely visited. What visitors type is never recorded.
After a submit, Mo watches for up to 10 seconds. With no validation error in that time, the submission counts as successful when any of these happens:
mo.complete()If your form stays on screen with its values still filled in after a successful send, or posts back to the same URL, Mo cannot tell success from failure. Tell it directly, in your success handler only — never on an error:
await sendEnquiry(data) // optional chaining: safe if Mo is blocked or not yet consented window.mo?.complete?.()
Calling it on the page where the form was sent is harmless even where Mo would have detected the success anyway — the submission is counted once. Adding it to every form's success handler is a safe default.
aria-invalid Browser validation (required, type="email") is detected automatically. If your own JavaScript validates the form, set aria-invalid="true" on each field it rejects, or on a wrapper around it. Mo records a validation error whenever a field becomes marked — after an attempt, or as the visitor leaves the field — and reads a marker appearing after a submit as a failed attempt. Screen readers announce it too: it is the accessible way to flag an error anyway.
<input id="email" aria-invalid="true" aria-describedby="email-error"> <p id="email-error">Enter a valid email address</p>
If your framework marks errors with a class instead, list it on the script tag and Mo reads it the same way, on the field or its wrapper. A marker on an element that holds no field — a button, a message — names no field and records nothing.
<script src="https://usemo.app/mo.js" defer mo-data-key="your_key" mo-data-error-class="has-error, is-invalid"></script>
<form> element If your submit button sends data with a click handler instead of a real <form>, no submit event fires. Mo still records a failed attempt on its own: a click on a button followed by aria-invalid="true" appearing on a field. To also record attempts that succeed, put mo-data-submit on the button itself (not on a container) so every click counts as an attempt:
<button mo-data-submit @click="send">Send</button>
mo-data-require-consent, nothing is recorded or stored until consent is granted.mo.complete() before navigating instead.Use the mo-data-track attribute to give elements meaningful names in your analytics:
<button mo-data-track="checkout-submit"> Complete Purchase </button> <a href="/pricing" mo-data-track="nav-pricing"> View Pricing </a>
Element identification priority: id → name → mo-data-track → grouped button → data-*-id → placeholder → <label> → aria-label → text content → tag name
The order above decides what a field is called in your dashboard, and it leads with id so a name you are already reading does not move under you. The Page quality report asks a different question — whether a screen reader has anything to announce — and answers it in accessibility order: aria-labelledby → aria-label → <label> → contents → title. Contents include alt text on an image and visually hidden text such as .sr-only, because both are announced in full. So an icon-only button keeps whatever field name its id gives it, and still counts as named once it has an aria-label.
By default, Mo uses the URL path as the step name for funnel tracking. You can override this with meaningful names using the mo-data-step attribute or JavaScript API.
Add mo-data-step to any container element (typically <body> or a wrapper <div>):
<body mo-data-step="Payment Details"> ... </body>
Call mo.setStep() when entering a new funnel step:
// Set the current step name mo.setStep('Payment Details'); // Set step with section in one call mo.setStep('Payment Details', { section: 'Checkout' });
On a single-page app, call it from your router after each navigation (vue-router's afterEach, for example). Mo records the page view when the URL changes and renames it when setStep() follows, so each page is one page view and its fields are read once the new page is on screen. Calling it again for the step already showing records nothing.
Sections group related steps together for filtering in the dashboard. Use mo-data-section or the JavaScript API to define sections.
Combine mo-data-section and mo-data-step on any container element:
<body mo-data-section="Checkout" mo-data-step="Payment Details"> ... </body>
Set section once at the start of a flow, then update steps as the user progresses:
// Set section once for the flow mo.setSection('Checkout'); // Then set steps as user progresses mo.setStep('Cart'); // ... user clicks next ... mo.setStep('Shipping'); // ... user clicks next ... mo.setStep('Payment');
The section persists across navigation until explicitly changed.
An e-commerce site with multiple flows:
| Section | Steps |
|---|---|
| Signup | Email, Verify, Profile |
| Checkout | Cart, Shipping, Payment, Confirm |
| Quote | Details, Coverage, Review |
Once sections are configured, use the Section dropdown in the dashboard header to filter analytics by section. This allows you to analyze each funnel independently.
Use mo-data-skip to disable tracking for specific pages or sections of your application.
Add mo-data-skip to any container element to disable all tracking within that page:
<body mo-data-skip> <!-- No events will be tracked on this page --> </body>
Use the JavaScript API to check if tracking is currently skipped:
// Check if tracking is disabled if (mo.isSkipped()) { console.log('Tracking is disabled on this page'); }
Add mo-data-no-values (attribute or class) to a field or container to stop Mo recording which option was chosen — for sensitive dropdowns, radios or checkboxes. The field's use is still tracked.
mo-data-quality-skip hides an element from the Page quality report without affecting tracking. Use it for markup that is repeated on purpose and already correct — licence and attribution links, a credits block, a card grid whose links all point at the same page. Clicks on those elements are still recorded and still appear in Fields.
It works as an attribute or a class, and applies to everything inside the element it is on — so one mark on a wrapper covers the whole block:
<div mo-data-quality-skip> <a href="...">CC BY-SA 3.0</a> <a href="...">Wikimedia Commons</a> </div> <!-- the class does the same thing --> <footer class="credits mo-data-quality-skip">…</footer>
Mo reads your page structure from real visits, so the finding clears on the next visit after you deploy — not immediately. Load the page once yourself, then press "Check again" on the Page quality report. You can also suppress a phrase from the report itself, which applies straight away and does not need a code change.
Use mo.exclude() and mo.include() to control which URL paths are tracked.
window.mo exists the moment the script has run, but the first page view is not recorded until DOMContentLoaded. An inline <script> immediately after a normal (non-async, non-defer) Mo tag in the <head> therefore runs in time, and the excluded path is never recorded.
Two ways to miss the window, both of which fail quietly — the page view is already recorded and nothing reports an error. If the tag is async or injected by a tag manager, the document may already be parsed when Mo runs, so it records that first page view immediately. And a call made after the fact excludes only what follows it. If you cannot guarantee the ordering, use mo-data-skip on the page instead: it is read at capture time, so it cannot be too late.
Prevent tracking on specific paths or path patterns:
// Exclude a single path mo.exclude('/'); // Exclude multiple paths mo.exclude(['/', '/admin', '/dashboard']); // Exclude with wildcard (matches /admin and /admin/*) mo.exclude('/admin/*');
Only track specific paths. When inclusions are set, only matching paths will be tracked:
// Only track checkout flow mo.include('/checkout/*'); // Track multiple specific flows mo.include(['/checkout/*', '/signup/*', '/onboarding/*']);
| Pattern | Matches |
|---|---|
| /checkout | /checkout, /checkout/step1, /checkout/payment |
| /checkout/* | Same as above (explicit wildcard) |
| / | Home page only (exact match) |
mo-data-skip attribute always takes precedence// Check if current path is excluded if (mo.isPathExcluded()) { console.log('This path is not being tracked'); }
A nudge is a small help card that appears on your page while a visitor's answer to one question matches a rule you set — for example, a note about licence requirements when they pick a certain vehicle. You write the rule, the card text and the field it belongs under on the Nudges tab, and Mo finds that field on the page by the identity it already tracks it with — your site needs no change. Tagging is for an element Mo doesn't track.
<div mo-data-nudge="start-hour">…</div>
For a heading, a price box or anything Mo does not track as a field. The tag value is the nudge's key; a CSS selector from the Nudges tab is the other way. On the page, the tag wins, then the chosen field, then the selector.
inside (the default) — the card goes at the end of the tagged element. A form control such as an input or select cannot hold a card, so it goes straight after it instead.after — the card goes immediately after the tagged element.parent — the card goes at the end of the tagged element's parent, which suits a field that sits in a row. A rule is one field and one answer. It is compared in the visitor's browser, against the live control, so nothing typed is sent to Mo. A nudge event records only the nudge's key, the step and its title. A field marked mo-data-no-values cannot be read by a rule at all.
// Fires on the tagged element and bubbles. action is // 'shown', 'dismissed', 'cta' or 'changed' document.addEventListener('mo:nudge', e => { if (e.detail.action === 'cta') { // e.detail.key is the nudge's key, e.g. 'start-hour' openChat(); } });
If you use mo-data-require-consent, no nudge is fetched or shown until mo.grantConsent() has been called. Nudges follow the same rules as everything else Mo does.
The Nudges tab counts, for each nudge, how many visits saw the card (Shown) and splits them into Changed answer, Dismissed and Ignored (a click on the card's button counts as Dismissed unless the answer then changes). On Journeys the nudge is a stop on the route, followed by its branch. The tracker fetches your nudges once per page load and the browser keeps that list for a minute, so a nudge saved on the Nudges tab reaches visitors within a minute of their next page load; mo.nudges.refresh() re-fetches at once.
mo-data-no-values to any field where that choice is sensitive. Things that stop a first install, and what fixes each. Start with debug mode: every decision Mo makes is logged with a [Mo] prefix, and the Live Activity panel on your dashboard shows events as they arrive, unfiltered.
Open your site with ?mo_debug=1 on the address and look for [Mo] in the browser console. The first lines say whether the key was found and whether tracking is on for the page.
No key: the attribute is mo-data-key, on the script tag itself. Flush failed, origin not allowed: the page's origin is not in the project's allowed URLs (Config tab). The allowlist fails closed, and the origin includes the port, so a local build needs http://localhost:3000, not just localhost. Awaiting consent: you added mo-data-require-consent, so nothing is recorded until your banner calls mo.grantConsent(). Skipped: the page, or an ancestor element, carries mo-data-skip, or the path is excluded.
Events are sent every 15 seconds and when the tab is hidden, so give it a moment; the Live badge on the dashboard shows how old the newest event is. An ad blocker on your own browser can also block the request: check the Network tab for /api/v1/track.
Mo loads from https://usemo.app and sends data there with fetch and sendBeacon. Allow it in both directives:
script-src ... https://usemo.app;
connect-src ... https://usemo.app;
A blocked script shows in the console as a CSP violation naming the directive. Nothing else is loaded: no fonts, images or frames.
On a single-page app the URL changes before your router has resolved the page, so Mo records the page view under the path first. When you then call mo.setStep() (from router.afterEach, say), Mo renames that view rather than adding one, so each page is a single step.
If you still see both, your browser is running an older copy of mo.js: it is cached for five minutes and refreshed in the background, so the first load after a change answers from cache. Load the page twice, or check the Network tab for the new file. The steps recorded while it was wrong stay in your data; Reset everything in the Config tab's Danger zone clears a project you were only testing with.
With server rendering, the Mo script often loads and records the first page view before your app has started, so a setStep() wired to the script's onload never runs: that handler is not attached to a tag the server rendered. Name the page once your app is up, and again when the script lands if it was still loading:
// Nuxt plugin (client)
const name = () => window.mo?.setStep(STEP_NAMES[router.currentRoute.value.name])
router.isReady().then(name)
document.addEventListener('load', (e) => {
if (e.target?.src === 'https://usemo.app/mo.js') router.isReady().then(name)
}, true)
router.afterEach((to) => window.mo?.setStep(STEP_NAMES[to.name]))
Calling setStep() twice for the page already showing records nothing, so both firing is harmless. Check the route name you key on: a page named with definePageMeta({ name }) is not called index.
You do not have to name steps in code at all. Mo records the path, and page groups on the Config tab can give /cover-details the name "Cover details" from the dashboard.
Mo sees a submit when a <form> fires its submit event. A page built as a <div> of fields with a Continue button that runs its own validation fires none.
Without any change Mo still records a failed attempt: a click on a button followed by aria-invalid="true" appearing on a field. To record attempts that succeed as well, add mo-data-submit to the button (see Form submissions). Completion is also judged by order: once you save a step order on the Goals tab, a visitor who reaches a later step counts as having completed the earlier one, submit event or not. For a multi-step journey, set the goal to Reached the page on your thank-you page.
Mo records a validation_error when a field becomes marked: aria-invalid="true" on the control or on a wrapper around it, or a class you list on the script tag with mo-data-error-class="has-error, is-invalid". The browser's own validation (required, pattern) is read as well. A marker on something that holds no field — a button whose text turns to "1 error on the page", a message paragraph — names no field and records nothing, so mark the field or its wrapper too.
An error shown as the visitor leaves a field (validate on blur) is recorded as an error, not as an attempt: no form_submit comes with it. An attempt is a submit event, a click on a mo-data-submit button, or a click on any button that an error then follows. A field already marked when the button is clicked is not a new error, so a Continue button that stays red adds nothing on the next click; mo-data-submit makes every click an attempt.
If the field is marked and still nothing arrives, the browser is probably running a copy of mo.js from before 5 October 2026, when an error with no attempt before it was not recorded. It is cached for five minutes and refreshed in the background, so load the page twice.
Within ten seconds of the attempt Mo needs one of: a navigation to another step, the form being removed or hidden, the form being emptied or reset, or a call to mo.complete(). A form that shows its thanks while keeping its filled values on the same page gives none of them, and nor does a full-page post that lands back on the same URL. Call mo.complete() when your success state shows. A focus or change inside the form after the attempt is read as going back to fix an error, so a script that moves focus into the form after a successful send should call mo.complete() first.
Mo takes an inventory of a page's fields once the new page has actually arrived, which on a single-page app is some time after the URL changes. Inventories recorded by older copies of mo.js under a setStep() name could hold the page being left. Current mo.js waits for the new page in every case. To drop the old rows, use Reset on the Page quality tab (inventories and suppressions only) or Reset everything on the Config tab.
A step is identified by its lower-cased name, so "Thank You" and "Thank you" are one step; the dashboard capitalises each part for display. Two rows that differ only in case can only come from a browser still running a copy of mo.js from before October 2026 and will merge on their own. Paths are shown as recorded (/contact); use page groups on the Config tab to show a path under a friendlier name.
Mo names a field by its id, then name, then mo-data-track, then what the element looks like: a label, placeholder, aria-label or its text (see Element names). An element with none of those falls back to its tag and pools with every other such element on the page. Give it an id, or mo-data-track="your-name". A name that changes per visitor usually has a live value in its text (a count, a price): that text is dropped where it is glued to a word, but a free-standing number stays, so name the element explicitly.
Make a separate project for local development with http://localhost:3000 (or your dev origin) as its allowed URL and its own key, read from an environment variable in your app. The live project's allowlist then refuses anything sent from a developer's machine. Clear a test project at any time with Reset everything (Config tab, Danger zone); keys and allowed URLs survive it. Mo also sets automated browsers apart as bots on its own, so a Playwright or Cypress run never counts as visitors.
The check fetches your page's HTML from our server and looks for the script tag. A tag added by a tag manager, injected by JavaScript, or on a page behind a login or a geo-block is invisible to it, and so is a site that serves bots a different page. In those cases the setup page says it cannot tell and offers the live check instead: open your site in a browser and the first event appears on the setup page within seconds.