invisit
All posts

By Miraç Berk12 min read

How to add analytics to WordPress without a plugin or cookies

A tested must-use plugin for WordPress analytics: editors left out, WooCommerce orders counted, caching plugins fixed, and 7 analytics plugins compared.

A soft 3D web page with a small script tag sliding into its header next to a shopping bag, lit by violet dithered light and soft shadows.

To add analytics to WordPress without an analytics plugin, save a short must-use plugin in wp-content/mu-plugins that loads the tracker with wp_enqueue_script(), adds its settings through the wp_script_attributes filter, and skips anyone who can edit posts. Then exclude that script from your optimisation plugin’s delay and combine settings, the two features that silently lose page views.

We tested every step on November 3, 2026: WordPress 7.1.4 on PHP 8.3 in Docker, block theme Twenty Twenty-Five and classic theme Twenty Twenty-One, WooCommerce 11.2.1, LiteSpeed Cache 7.9.2 and Autoptimize 3.1.17, in Chrome 155, checked against the events the collector stored. The tracker is invisit, the cookieless analytics tool we build, which has no WordPress plugin. The steps work for any script tag tracker, and a table further down compares the plugins most sites install instead.

Where should analytics code go in WordPress?

Put analytics code in a must-use plugin. WordPress loads every PHP file directly inside wp-content/mu-plugins on every request, before normal plugins, and such a file cannot be deactivated from wp-admin (Must-Use Plugins), so tracking survives theme updates, theme switches and a tidy-up of the Plugins screen.

Where the code lives Survives a theme update Survives a theme switch Catch
Parent theme header.php or functions.php No No The next theme update overwrites it
Child theme functions.php Yes No Tracking stops the day you change themes
A header and footer snippets plugin Yes Yes One more plugin, printing raw HTML
Must-use plugin Yes Yes No update notices; only top-level files load

Block themes change less than they seem to. A block theme has no header.php to paste into, but WordPress renders it through template-canvas.php, which still calls wp_head() and wp_footer(), so anything you enqueue lands in the same place. Our test printed the identical tag in the <head> of Twenty Twenty-Five and of Twenty Twenty-One.

How to add a tracking script with wp_enqueue_script

Enqueue the script on the wp_enqueue_scripts action with a loading strategy, then add the tracker’s data- attributes through the wp_script_attributes filter. Create this file:

wp-content/mu-plugins/invisit.php
<?php
/**
 * Plugin Name: invisit analytics
 * Description: Loads the invisit tracker on every front-end page, except for signed-in editors.
 */

defined('ABSPATH') || exit;

const INVISIT_SITE = '<site id>';
const INVISIT_COLLECTOR = 'https://api.invisit.dev';

add_action('wp_enqueue_scripts', function () {
    // Authors, editors and admins browse their own site all day. Leave them out.
    if (current_user_can('edit_posts')) {
        return;
    }
    wp_enqueue_script('invisit', INVISIT_COLLECTOR . '/invisit.js', [], null, ['strategy' => 'async']);
});

// WordPress 6.4 and later build every enqueued script tag through this filter.
add_filter('wp_script_attributes', function (array $attributes): array {
    if (($attributes['id'] ?? '') === 'invisit-js') {
        $attributes['data-site'] = INVISIT_SITE;
        $attributes['data-collector'] = INVISIT_COLLECTOR;
        $attributes['data-cfasync'] = 'false'; // Cloudflare Rocket Loader: leave this script alone
    }
    return $attributes;
});

The site id is under Settings, Installation in the invisit dashboard. Four details are specific to WordPress:

  1. The version argument is null. With the default false, WordPress appends ?ver= and your WordPress version to the URL; null adds nothing (wp_enqueue_script).
  2. 'strategy' => 'async' uses the loading strategies WordPress 6.3 added and prints async.
  3. WordPress names the tag after the handle plus -js, so the handle invisit becomes id="invisit-js", which is what the filter matches.
  4. wp_script_attributes passes the attributes as an array. The filter dates from WordPress 5.7, but only 6.4 routed enqueued scripts through it (script loading changes in 6.4). On 6.3 or older, use script_loader_tag, which hands you the finished HTML string instead.

This is the tag WordPress 7.1.4 printed, shown with the production address where our test used a development collector:

View source, logged out
<script
  async
  data-cfasync="false"
  data-collector="https://api.invisit.dev"
  data-site="<site id>"
  data-wp-strategy="async"
  id="invisit-js"
  src="https://api.invisit.dev/invisit.js"
></script>

WordPress adds data-wp-strategy itself and writes new attributes in alphabetical order, which puts data-cfasync before src, the order Cloudflare requires. In Chrome the page sent its page view, and the collector stored it with the right hostname and path. The tracker set no cookies and wrote nothing to localStorage.

Keep data-collector: without it, the script sends events to your own domain, which only works behind a proxy (our proxy guide). The invisit docs show a shorter route, a <script> tag echoed on wp_head from a child theme (WordPress setup). It prints the same tag, but only an enqueued script passes through wp_script_attributes, the filter WordPress gives for adding a Content Security Policy nonce.

How do I keep logged-in editors out of WordPress analytics?

Check a capability, not the login. current_user_can('edit_posts') is true for contributors, authors, editors and administrators, so your team’s pages never load the tracker, while subscribers and WooCommerce customers, who are real visitors, still count. In our test a signed-in editor and administrator got no tracker, and a signed-in subscriber was counted.

Analytics plugins differ here, which matters on a shop or membership site:

Plugin Who it leaves out by default
Site Kit by Google All logged-in users (Analytics settings)
Independent Analytics All logged-in users, unless you turn tracking them on (plugin source)
Plausible Analytics Administrators; other roles are optional (plugin docs)
Fathom Analytics for WP Nobody until you tick roles (Fathom’s docs)
Matomo for WordPress, Koko Analytics Nobody until you pick roles (plugin source)

Leaving out every logged-in user also leaves out the customers who sign in before they buy.

Page caching keeps the check working, with one exception. WP Rocket serves cached pages only to logged-out visitors unless you turn on its per-user cache (WP Rocket), but Cloudflare’s Cache Everything “does not distinguish between logged-in and logged-out users” (WP Rocket’s User Cache notes), so behind it your editors get the cached page, tracker included. For that case, and for the phone you never sign in on, open the site once in each browser with #invisit-ignore at the end of the address. Our internal traffic guide covers previews, staging and the equivalent in other tools.

Which caching and optimisation settings break analytics?

Two settings lose page views: delaying JavaScript until the visitor interacts, and combining the tracker into one bundle with other scripts. Page caching and defer do not stop a tracker loaded from another domain.

Delayed JavaScript drops visitors who never interact

Delay JavaScript holds scripts back until the first mouse move, tap, scroll or key press. WP Rocket gives every script a fake type and moves src to data-rocket-src (Delay JavaScript execution); LiteSpeed Cache rewrites the tag to type="litespeed/javascript" data-src=.... A visitor who reads and leaves without touching anything is never counted.

We measured it with LiteSpeed Cache’s Load JS Deferred set to Delayed, on a tag without data-cfasync. With no interaction for 15 seconds, Chrome did not even download invisit.js. After a mouse move at 1.5 seconds, the tracker loaded, kept its data attributes and sent the page view. With invisit.js listed in JS Deferred / Delayed Excludes, the tag stayed as WordPress printed it and the page view went out on load.

You will not see this while signed in. WP Rocket applies delay “only on cached or optimized pages” and serves logged-in users neither, so test from a private window.

Combining moves the tracker away from its settings

A script tag tracker reads its settings from its own tag. invisit’s reads data-site and data-collector from document.currentScript, the tag that is running. Bundled into a combined file, the running tag is the bundle’s, which has no data-site. We served such a bundle from the WordPress site: the tracker logged [invisit] missing site or collector; tracker not started and sent nothing. Fathom’s WordPress docs warn that its script fails the same way when combined or minified with local scripts.

WP Rocket’s Combine JavaScript files “applies also to inline JavaScript and 3rd party scripts” (WP Rocket). LiteSpeed Cache takes in other domains’ scripts only with JS Combine External and Inline turned on. Autoptimize leaves scripts from other domains out of its bundle as “non-mergeable”, and our tag came through its aggregation untouched. Exclude the tracker from minifying too: invisit loads its optional modules from next to its own file, so a local copy loses them.

How to exclude the tracker in each plugin

Plugin or service Setting that breaks counting Where to exclude the tracker
WP Rocket Delay JavaScript execution, Combine JavaScript files File Optimization, Excluded JavaScript Files: invisit.js, or a nowprocket attribute
LiteSpeed Cache Load JS Deferred: Delayed, JS Combine External and Inline Page Optimization: invisit.js in JS Deferred / Delayed Excludes and in JS Excludes
Perfmatters Delay JavaScript with the Delay all scripts behaviour JavaScript, Excluded from Delay: invisit.js
Autoptimize None for scripts on other domains Nothing to do
Cloudflare Rocket Loader Rocket Loader on data-cfasync="false" before src

Sources: WP Rocket’s delay and external scripts articles, Perfmatters, Cloudflare, LiteSpeed Cache 7.9.2 itself. For nowprocket, add $attributes['nowprocket'] = true; in the filter.

The data-cfasync="false" attribute in the must-use plugin does double duty. LiteSpeed Cache skips any script that carries it before combining, delaying or deferring anything, and with it in place our tag came through Delayed mode untouched. Matomo for WordPress has a setting that adds the same attribute to its own tag. After changing an exclusion, clear every cache, since cached pages keep the old tag.

How to track WooCommerce orders from PHP hooks

Hook woocommerce_thankyou, which WooCommerce runs on the order received page, and print one conversion call per order. The hook fires on every load of that page, reloads included, so the code marks each order once it has been counted. Add this to the same must-use plugin:

wp-content/mu-plugins/invisit.php
add_action('woocommerce_thankyou', function ($order_id) {
    $order = wc_get_order($order_id);
    // The thank-you page runs this on every load, reloads and failed payments included.
    if (!$order || $order->has_status('failed') || $order->get_meta('_invisit_counted')) {
        return;
    }
    $order->update_meta_data('_invisit_counted', time());
    $order->save();

    $properties = wp_json_encode([
        'payment' => $order->get_payment_method(),
        'items' => $order->get_item_count(),
        'paid' => $order->is_paid(),
    ]);
    wp_print_inline_script_tag(<<<JS
    (function (properties) {
      var send = function () { window.invisit && window.invisit.conversion('order_placed', properties) }
      var tag = document.getElementById('invisit-js')
      if (window.invisit) send()
      else if (tag) tag.addEventListener('load', send)
    })($properties)
    JS);
});

Why the code looks like this:

What we saw in Chrome, signed in as a customer and checked in the collector’s stored events:

Order Theme First load of the thank-you page Reload
Card payment, status Processing Twenty Twenty-Five (block) Page view and one order_placed, paid: true Page view only
Bank transfer, status On hold Twenty Twenty-One (classic) Page view and one order_placed, paid: false Page view only
Failed payment Twenty Twenty-One (classic) Page view only Not tested

The recorded addresses had lost their key=wc_order_... parameter, because invisit drops query parameters named key by default. Our first run counted nothing: a fresh WooCommerce install starts in Coming soon mode, which hides the order from customers, so switch it off under Site visibility before testing.

This counts orders confirmed in the visitor’s browser. A payment that clears later, such as a bank transfer marked paid days afterwards, happens in PHP with no visitor on the page, and invisit has no PHP SDK to send it: @invisitdev/server runs on JavaScript runtimes, not PHP. Plausible’s official plugin does more here, tracking add to cart, checkout and purchases with revenue for WooCommerce without code (Plausible), though revenue goals need its Business plan, from $19 a month (our comparison). invisit does not connect to WooCommerce; if your store takes cards through Stripe, connecting Stripe brings in payments, refunds and fees, without attributing them to sources.

WooCommerce sets cookies of its own

Cookieless analytics does not make a WooCommerce store cookieless. WooCommerce’s Order Attribution feature loads sourcebuster.js and sets sbjs_* cookies that expire with the session (WooCommerce). On our fresh store, the first anonymous page view set seven of them. With Order Attribution off, under WooCommerce, Settings, Advanced, Features, the same page set none, and invisit still counted the visit. WooCommerce says the feature works with consent tools through the WP Consent API. Whether session cookies like these need consent where you sell is covered in our cookie banner guide.

WordPress analytics plugins compared

If you want the numbers in your own database and no third party, Koko Analytics, Independent Analytics, Burst Statistics and Matomo for WordPress all store them in WordPress. If you would rather not run analytics on your web server, the official Plausible and Fathom plugins load their hosted scripts. Site Kit by Google installs Google Analytics 4, which sets cookies. As of November 2026:

Plugin (active installs) What it adds to each page Cookies by default Where the data lives Price
Site Kit by Google (5M+) Google’s gtag.js, 155 to 179 KB gzipped _ga and _ga_<ID>, 2 years Google Analytics Free
Matomo for WordPress (100K+) matomo.js from your site, 22.1 KB gzipped Visitor cookie for 13 months; can be turned off Your WordPress database Free; premium features extra
Koko Analytics (70K+) 1,754 bytes of inline script, no file One cookie, up to 24 hours; cookieless mode Your WordPress database, as counts Free; Pro €59 a year for 1 site
Independent Analytics (100K+) An inline script that posts each view to your site’s REST API None Your WordPress database Free; Pro $49 a year for 1 site
Burst Statistics (200K+) Two files, 7.1 KB gzipped, plus 12.8 KB of core scripts Yes; cookieless option Your WordPress database Free; Pro from $49 a year
Plausible Analytics (10K+) Plausible’s script, 2.5 KB gzipped, optionally through your domain None Germany (Hetzner) Plan from $9 a month; 30-day trial
Fathom Analytics for WP (10K+) Fathom’s script, 2.1 KB gzipped None US; EU visitors processed in the EU first Plan from $15 a month
invisit, the must-use plugin above invisit.js, about 8.5 KB gzipped None Falkenstein, Germany (Hetzner) Free to 20,000 events, then from $14

Active installs come from WordPress.org. We measured Matomo for WordPress 5.13.1 (which bundles Matomo 5.13.0), Koko Analytics 2.5.3 and Burst Statistics 3.7.4 in our test site, gzip at level 9; the Google, Plausible, Fathom and invisit sizes come from our script size measurements. Burst’s extra 12.8 KB is wp-api-fetch and four core scripts it needs, free if your site already loads them. Sources for cookies, data and prices: Google, Matomo, Koko’s tracking methods and pricing, Independent Analytics, Burst, our comparisons of Plausible and Fathom, and invisit pricing.

The plugins that keep data in WordPress move the cost to your server. Every page view becomes a PHP request and a database write: Matomo’s readme warns of “an additional request for each page view”, Independent Analytics estimates 200 to 300 MB of database per million sessions, and Koko deletes data older than 36 months by default. On shared hosting with real traffic, that is where a hosted tool earns its fee.

Plausible’s plugin is the most complete WordPress integration here: a one-click proxy under /wp-json, author and category tracking, and WooCommerce and Easy Digital Downloads funnels without code. Site Kit is the pick if you live in Search Console and AdSense, at the price of a consent banner for EU and UK visitors (our GA4 comparison).

Is there an invisit WordPress plugin?

No. As of November 2026 invisit has no WordPress plugin. Its WordPress setup prints the script tag on wp_head from a child theme’s functions.php, skipping users who can edit posts, or pastes it into the header with a code snippets plugin; the must-use plugin above does the same through wp_enqueue_script(). data-invisit-event attributes in a Custom HTML block record clicks as events without JavaScript.

The server SDK that serves the tracker from your own domain and records crawler reads, @invisitdev/server, does not run on PHP. For a WordPress site the docs put a Cloudflare Worker in front of it instead.

Checklist before you trust your WordPress numbers