Script options
Every data- attribute, and what happens at each limit.
Every option is a data- attribute on the script tag, and they all work on either form of the
snippet. It’s the same bundle whichever filename you load - no build flags, no feature variants to
choose between, and nothing you can forget to turn on.
A value is required, not just the attribute
This trips people up, so it goes first. A bare attribute does nothing:
<script defer data-hash src="…"></script> <!-- ignored -->
<script defer data-hash="true" src="…"></script> <!-- on -->
Any non-empty value switches a flag on, including the string "false". To turn something off, leave
the attribute out.
The attributes
data-domain
The site this page belongs to, as registered. It’s the routing key for the whole system.
Your per-site script file carries that value inside it, so on the snippet the setup screen gives you
there’s no attribute to set and this one is ignored. It’s required on the shared script.js, and
that’s the only place it does anything.
A page whose domain doesn’t match a registered site is dropped with the reason unknown_site, which
comes back on the response - see checking what happened to an event.
Load the shared script.js with no data-domain at all and the script installs a working but inert
window.feasible and logs feasible: not tracking - no data-domain.
data-api
Where events are sent. Set it when you proxy through your own domain. The value is used as given - nothing is appended - and a relative path works.
The default is the origin of the script’s own src plus /api/event. Note that it’s the origin
and not the directory: a script served from /stats/js/script.js still posts to /api/event by
default, which is why a path-based proxy has to set this.
data-exclude
A comma-separated list of paths that are never counted. Matched against pathname + hash, anchored
at both ends.
*matches within one path segment.**matches across segments, so/admin/**excludes a whole subtree.- Everything else is a literal, including
?and..
An excluded page sends nothing at all - no pageview, no custom event, no outbound click. There’s no
data-include: write the exclusion.
data-file-types
The file extensions that count as a download. Comma-separated, no spaces, with or without a leading dot, case-insensitive. Setting it replaces the built-in list rather than adding to it.
The built-in list is 7z avi csv dmg docx exe gz key midi mov mp3 mp4 mpeg pdf pkg pps ppt pptx rar rtf txt wav wma wmv xlsx zip.
data-hash
Treat the URL fragment as part of the page, for a router that uses hashes. Off, a fragment change isn’t a new pageview; on, it is.
Set it only if your routing needs it. On an ordinary page it turns every in-page anchor into a pageview.
data-manual
Stop automatic pageviews and send them yourself with feasible('pageview').
It suppresses the first pageview and the history listeners, and nothing else: engagement measurement, outbound clicks, downloads and form submissions all keep working. Use it sparingly - a manual pageview that never gets called is data nothing can recover.
data-alias
Expose the same function under a second name on window, for a site that already has its own
feasible global or a house convention to follow.
The tracker always installs window.plausible as a second name for the same function, so a site
arriving with existing calls under that name keeps working without this attribute.
data-capture-on-localhost
Count traffic on a local address. Off by default, so development traffic never reaches your real numbers.
The older squashed spelling data-captureOnLocalhost is still accepted, and so is the squashed form
of every other hyphenated attribute - data-filetypes works like data-file-types. New
snippets should use the hyphenated names.
Your LAN is not localhost
What counts as local is narrower than people expect: the hostnames localhost, 127.0.0.1,
0.0.0.0, ::1, an empty hostname, and any file: page.
Private LAN addresses are not excluded. A site opened at 192.168.1.20 or 10.0.0.5 is counted
normally, which is worth knowing before you demo a staging build to a room.
data-vitals
Enable Web Vitals as an optional mode of the same tracker script. A bare attribute captures every page; a decimal value between 0 and 1 samples that fraction of documents:
<script defer data-vitals="0.1" src="https://app.feasible.lol/js/fs-k7m2q4x5r3n6t2v5.js"></script>
It reports LCP, CLS, INP and TTFB as an event called Web Vitals, with one numeric property per measurement - so every one of them is a custom property you can aggregate, filter and break down by page like any other. Nothing in it reads layout: every number comes from the maintained Web Vitals implementation over the browser’s Performance API.
The one script tag is still the entire installation. The base tracker stays at 3,377 bytes gzipped, under its 3,584-byte build budget, and downloads the Web Vitals module only when this option is on, so sites that leave it off don’t pay for the optional collector.
It sends final values when a navigation is hidden. A supported single-page navigation starts a fresh set of route-scoped measurements, and restoring a document from the back-forward cache starts a fresh observation. Cumulative layout shift and interaction to next paint aren’t final on load, so reporting them earlier would produce a smaller value than the visitor experienced.
Web Vitals are a distribution rather than a total, so a tenth of your traffic estimates the curve
directly rather than being multiplied by ten. Sampling is never enabled silently: data-vitals means
every document and data-vitals="0.1" means one in ten. The earlier data-vitals data-sample="0.1"
spelling still works as an alias; new installs should put the rate directly on data-vitals.
The event counts toward your monthly volume like any other custom event, which is the other reason it’s opt-in. At a sample of one in ten, that’s one extra event per ten measured navigation lifecycles.
Consent and Do Not Track
Set window.__feasible = { consent: false } before loading the script when your consent manager
denies analytics. The tracker also honors a browser Do Not Track value of 1.
In either case it sends no pageview and no custom event, installs the callable API, and answers a
supplied callback immediately with { status: null }.
Most sites don’t need a consent banner for this design at all - there are no cookies and no stored identifier. See privacy and GDPR for the facts to hand your lawyer.
Opting one browser out
To stop your own visits counting on a machine, in that browser’s console:
localStorage.setItem("feasible_ignore", "true")
The value has to be "true". Remove the key to start counting again. This is a per-browser
setting and needs no attribute on the tag.
If you’d rather exclude a whole office or a home address, use an IP shield instead.
What the script does without being asked
These aren’t options and can’t be switched off, because a build flag for each is a build flag somebody forgets:
- Client-side route changes.
pushState,replaceState,popstateandhashchangeare all handled. Consecutive changes in one tick collapse into a single pageview at the final URL. - Outbound link clicks - event name
Outbound Link: Click, with the destination in aurlproperty. Middle-clicks count; right-clicks don’t; nothing is everpreventDefault-ed, so modifier-clicks behave normally. - File downloads - event name
File Download, also with aurlproperty. A link counts if it has thedownloadattribute, thedata-fs-downloadattribute, or an extension on the list above. - Form submissions - event name
Form: Submissionunless you name it. Existing integrations sendingForm: Submitstay compatible. - Revenue on any custom event. There’s nothing to enable.
- Engagement - time on page and scroll depth. There’s no polling timer: a measurement is flushed when the tab is hidden, blurred or navigated away from, and only if it got deeper or accrued at least three more seconds. Time accrues only while the tab is both visible and focused, so a page left open behind another window doesn’t bank hours.
Outbound clicks, downloads and form submissions are custom events, so they count toward your monthly volume. Engagement pings never do.
404 tracking and site-search tracking aren’t in this script. They come from the WordPress plugin, which can see both server-side.
Tagging an element without writing JavaScript
Put a class on the element. The script looks at the clicked element and up to three ancestors:
<a class="feasible-event-name=Signup feasible-event-plan=pro" href="/signup">Sign up</a>
feasible-event-name=… sets the event name, and any other feasible-event-<key>=<value> becomes a
property. A class attribute can’t hold a space, so + decodes as one:
feasible-event-name=Article+Saved sends Article Saved.
Classes spelled plausible-event-… are read the same way, so a site arriving with those already in
its markup can leave them alone. Both the = form and the site-builder-friendly -- separator are
accepted, including event properties and tagged forms.
Calling it yourself
feasible('Signup', { props: { plan: 'yearly' } })
feasible('Purchase', { revenue: { amount: 49.00, currency: 'USD' } })
feasible('Heartbeat', { interactive: false })
feasible('pageview', { u: '/checkout/step-2' })
plausible(…) is a second name for feasible(…). Queued calls made before the tracker loads are
replayed through either name.
Two details that cost people an afternoon:
- A custom event takes
props. A pageview takesp.feasible('pageview', { props: … })silently sends no properties. - A
callbackis best-effort. A response gives you{ status: 202, dropped: null }, and an inline drop such as an unknown site or an IP shield gives{ status: 202, dropped: "shield_ip" }. A202means the ingest process accepted the request - country, page and hostname shields run later at the shard and can’t be seen by the browser callback. Consent denial, Do Not Track, bot detection, a missing domain, or a client-side path exclusion produces{ status: null }. The callback does not fire when the request failed or an ad blocker ate it, so gate forms with your own timeout.
Calling it before the script loads
The tag is deferred, so your own code can run first and find no window.feasible to call. The same
goes for async. Put the queue stub above the tag and those calls are replayed in order when the
script arrives, rather than throwing:
window.feasible = window.feasible || function () {
(window.feasible.q = window.feasible.q || []).push(arguments);
};
It’s 103 bytes minified, and it does nothing if you only tag elements with classes - those clicks are
handled by the script itself, after it loads. Leave it out unless you call feasible() yourself.
Content security policy
A strict CSP needs the analytics origin in two directives: script-src to load the file, and
connect-src for the request that carries the events.
Allowing only script-src gives you a script that loads and then sends nothing, which looks like a broken install. There’s no nonce support - allow the origin.
Proxying makes this go away entirely: once both requests are same-origin, a strict CSP needs no analytics host at all.
Failed requests are retried
Every event is written to the browser’s local outbox before its request starts, and replayed on the next pageview with the same permanent event id until a successful response removes it. There’s no arbitrary event-count eviction.
Current consent, Do Not Track, self-exclusion and route-exclusion rules are all applied again before replay. If storage is unavailable, the tracker keeps an in-page queue and logs that durability is memory-only.