Privacy-First Analytics
Insights without compromising user privacy.
Inertia Start gives you two privacy-friendly ways to understand your traffic. You can use either one, or both:
- Umami integration — a ready-made connection to Umami, an open-source web analytics platform. Umami is a separate application that you install and run yourself; Inertia Start ships only the code that talks to it.
- Contexts — Inertia Start's own way of recognizing anonymous visitors and following them from their first anonymous visit to their first purchase. Built in, always on, nothing extra to host.
Umami Analytics
Umami is open-source, easy to self-host, and GDPR-compliant. Inertia Start includes everything needed to connect your app to it.
Umami is a separate, external application
Inertia Start bundles the integration with Umami, not Umami itself. You need to install and run your own Umami instance — on the same server as your app or on another one — before the steps below will work.
Setup
Install Umami
Follow the Umami installation guide to set up Umami on your server. It can live on the same server as your Inertia Start app or on a separate one.
Cloudflare headers
If your Umami instance is behind a Cloudflare proxy, don't forget to enable Cloudflare headers.
Add your website to Umami
In your Umami dashboard, add your website. Umami will generate a Website ID that you'll need in the next steps.
Enable analytics in Inertia Start
Turn on the integration with the I_S_ENABLE_ANALYTICS environment variable:
I_S_ENABLE_ANALYTICS=truePoint Inertia Start at your Umami instance
Fill in the details of the Umami instance you set up in step 1:
# The URL where your Umami instance is reachable.
# If Umami runs on the same server with the default port, this is http://localhost:3000.
I_S_UMAMI_HOST=http://localhost:3000
# The name of the tracking script Umami injects into your pages.
# Defaults to "script.js", unless you changed TRACKER_SCRIPT_NAME on your Umami instance.
I_S_UMAMI_TRACKER_SCRIPT=script.js
# The Website ID that Umami generated when you added your website (step 2).
I_S_UMAMI_WEBSITE_ID=Usage
That's it! Once the configuration above is in place, Inertia Start automatically collects visitor statistics — there's nothing else to do.
To go further, the useUmami composable lets you track custom events from your Vue components:
import { useUmami } from '@/composables/useUmami';
const { track } = useUmami();
track('signup_button_clicked');The composable also exposes identify(id) to attach data to the current visitor and isAvailable() to check whether the Umami script has loaded.
Contexts
Umami tells you what happens on your site. Contexts tell you who keeps coming back — and they're built right into Inertia Start, so there's nothing to install and nothing to host.
The first time someone visits, Inertia Start creates an anonymous context for them. That context follows the visitor across your marketing site, your documentation and your app, and the day they create an account it becomes theirs — with their very first visit still on record.
Contexts are always active: unlike the Umami integration, they don't depend on I_S_ENABLE_ANALYTICS.
What a guest context actually stores
No name, no email address, no account data — just a non-reversible SHA-256 hash of the browser fingerprint and the IP address (never the raw values), plus a few timestamps. A context becomes linked to a real person only if that person registers.
What you get
- One visitor, one identity — the same person is recognized across your marketing site, your documentation and your app.
- A history that survives signup — first-seen and last-seen timestamps are preserved when a guest becomes a user.
- First-touch attribution — the campaign, landing page and referrer that first brought a visitor in are stored on their context and survive all the way to the purchase.
- Conversion insight — you can see how long someone browsed before signing up, how the account was created (
registered_via), and when they became a paying customer (converted_at, which the checkout flow fills in for you). - Umami sessions you can match — every first-party site can identify its Umami session with the same
context:{contextId}value.
Contexts live in the contexts table and are queryable through the App\Models\Context model. The admin users page is built on top of them, so it lists guests too, not only registered users, and every user page shows their whole tracking record.
How it works
The browser computes a fingerprint
Inertia Start's top-level layouts call the useContext() composable. For guests, it computes a Thumbmark fingerprint and attaches it to Inertia requests in the X-Guest header.
The server resolves the context
The SetContext middleware hashes that fingerprint together with the visitor's IP address, then finds or creates the matching row in the contexts table.
The context ID is remembered in the session and in a first-party context cookie for 90 days, and it's handed to Vue as the contextId Inertia prop. Guest contexts expire after 90 days, and the scheduled expirable:purge command removes them.
To avoid a database write on every request, last_seen_at is refreshed at most once every 5 minutes. Adjust that with I_S_CONTEXT_ACTIVITY_INTERVAL.
Because the fingerprint is combined with the IP address, the same browser on a different network resolves to a different context. This is the trade-off that keeps guest tracking anonymous.
The first arrival is attributed
On the request that creates the context, Inertia Start records where the visitor came from:
| Column | Contents |
|---|---|
utm_source, utm_medium, utm_campaign, utm_term, utm_content | The campaign parameters found in the landing URL's query string |
landing_url | The first page of yours the visitor opened |
referrer | The page that sent them, when it is not one of your own |
This is first-touch attribution: all seven columns describe the same arrival, so they are written once and never changed. A visitor who arrives through a Product Hunt link, leaves, and comes back a week later through a Reddit link is still credited to Product Hunt — the channel that actually did the work of finding them.
Because the columns are written together, a source from one arrival can never end up paired with a referrer or a campaign from another. A context created without a landing URL simply stays unattributed; the visitor was already known to you before the campaign link was clicked.
Combined with converted_at, this is what lets you ask which channel produced buyers rather than clicks:
Context::query()
->whereNotNull('converted_at')
->selectRaw('utm_source, count(*) as customers')
->groupBy('utm_source')
->orderByDesc('customers')
->get();Money lives in the billing tables rather than on the context, so join them when you want revenue per channel instead of customers per channel:
Context::query()
->join('product_purchases', 'product_purchases.user_id', '=', 'contexts.user_id')
->selectRaw('contexts.utm_source, count(distinct contexts.id) as customers, sum(product_purchases.amount) as revenue')
->groupBy('contexts.utm_source')
->orderByDesc('revenue')
->get();product_purchases.amount is stored in the currency's minor unit (cents), so group by currency as well if you sell in more than one.
Landing URLs are stored as they arrive
Query strings are recorded verbatim, so avoid putting anything personal in your own campaign links, and be aware that a third party could append parameters to a link they share. Values are truncated (255 characters per campaign parameter, 2000 per URL), and the public context API accepts a landing URL only when it is a well-formed absolute URL.
Registration keeps the history
When the visitor signs up:
- their current context is attached to the new account
registered_atandregistered_viaare filled in
The built-in email signup records signup as the registration method, and external auth providers record the provider's driver name (for example google). The column is a plain string, so your own flows can store whatever describes them best.
The first purchase marks the conversion
When a Stripe Checkout session or a Paddle transaction completes successfully — for a configured product or subscription, through either provider — the transaction timestamp is written to the context's converted_at.
When Umami is enabled, Inertia Start already ties the two systems together for you using:
window.umami.identify(`context:${contextId}`);so the context and its Umami sessions share a single identifier, ready to paste into Umami's session search.
What the admin shows
Every user page in the admin has an Analytics and tracking section built from the columns above:
- Lifecycle — first visit, registration, conversion and last activity, drawn as a timeline with the time elapsed between each step, next to the context ID. Milestones that haven't happened yet stay empty, so a visitor who registered but never paid is obvious at a glance, and the registration method appears as a badge on the registration step.
- Acquisition — the first-touch source (the
utm_source, falling back to the referring host, then to Direct), the rest of the campaign parameters, the landing page and the external referrer. - Umami session — the copyable
context:{contextId}value and a link straight to your Umami sessions. This block only appears when Umami is configured.
Sharing contexts with your other sites
If your marketing site or your documentation lives on another domain, they can resolve the same context before the visitor ever reaches your app, through the public POST /api/context endpoint.
Allow the other origins
List every first-party origin in your application's .env:
I_S_CONTEXT_ALLOWED_ORIGINS=https://www.example.com,https://docs.example.comOrigins are matched exactly: scheme and host, plus the port if there is one — no path, no trailing slash. Your application's own origin is always accepted, so you don't need to list it.
Use the same fingerprinting library
Install Thumbmark on the other site:
npm install @thumbmarkjs/thumbmarkjsKeep the version in sync with the one in your application's package.json. Two different versions can produce two different fingerprints for the same browser, which would split a single visitor into two contexts.
Resolve the context ID
Send the fingerprint to the endpoint in the X-Guest header. No cookie, no CSRF token and no API key are involved.
Include the current URL and the referrer in the body as well. The endpoint only ever sees its own URL, so without them a visitor arriving on your marketing site through a campaign link cannot be attributed:
import { Thumbmark } from '@thumbmarkjs/thumbmarkjs';
export async function resolveContextId(): Promise<string | null> {
try {
const { thumbmark } = await new Thumbmark().get();
const response = await fetch('https://app.example.com/api/context', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Guest': thumbmark,
},
body: JSON.stringify({
url: location.href,
referrer: document.referrer || null,
}),
});
if (!response.ok) {
return null;
}
const { contextId } = (await response.json()) as {
contextId?: number | string;
};
return contextId ? String(contextId) : null;
} catch {
return null;
}
}Always fail quietly like this. Context tracking is a nice-to-have: a rejected or unreachable request should never keep a page from rendering.
Both fields are optional and each is ignored unless it is a well-formed absolute URL of no more than 2000 characters. They are only used the first time a context is created, so sending them on every page view is harmless.
Point Umami at the same identifier (optional)
If that site also runs the Umami tracker, identify its session with the context you just resolved:
resolveContextId().then((contextId) => {
if (!contextId) {
return;
}
const identify = () => window.umami?.identify?.(`context:${contextId}`);
// The tracker script is usually deferred, so it may not be ready yet.
if (window.umami) {
identify();
} else {
document
.querySelector('script[data-website-id]')
?.addEventListener('load', identify, { once: true });
}
});Skip this step if you don't use Umami — cross-site contexts and conversion tracking work perfectly well on their own.
What the endpoint returns
A successful call replies with the context ID, and the response is never cached:
{
"contextId": 123
}| Status | When |
|---|---|
200 | The context was found or created. |
403 | The request carries an Origin header that is neither your application's own origin nor one of I_S_CONTEXT_ALLOWED_ORIGINS. |
422 | The X-Guest header is missing or empty. |
429 | More than 60 requests per minute from the same client. |
Browser preflight (OPTIONS) requests are handled for you, so there is nothing else to set up on the CORS side.