Script install

One script tag gives you page views, visitors, referrers, UTM campaigns, countries, devices, browsers and JavaScript errors. It sets no cookies and uses no local storage.

The snippet

<script>window.mosaicdeck=window.mosaicdeck||{q:[],track:function(){var q=window.mosaicdeck.q;q.length<50&&q.push([].slice.call(arguments))}}</script>
<script defer src="https://t.mosaicdeck.com/s.js" data-site="site_01J9XEXAMPLE00000000000000"></script>

Copy your own snippet, with your site ID, from the site's Connections tab. Put it in the<head> of every page.

The first line is a tiny stub: track() calls made before the script has loaded (a form submitted on a slow connection, say) wait in a queue of up to 50 and are sent as soon as it loads. Keep it before the tag. Without it the script still works, but those early events are lost.

Options

AttributeWhat it does
data-siteRequired. Your site ID.
data-errors="false"Turns off JavaScript error capture (on by default, at most 10 per page load).
data-outbound="true"Tracks clicks on links to other domains as the custom event outbound with the link's url.
data-respect-dnt="true"Skips tracking for visitors who have Do Not Track turned on.
data-api="https://example.com/relay"Sends events through your own domain instead (see first-party proxy).
data-mask="/challenge/* /join/*"Hides short codes in these routes: each * segment is sent as * (see private IDs in URLs).
data-mask-ids="false"Turns off automatic ID masking, so IDs in paths are sent as they are. Email addresses stay masked. Rarely what you want.

What the script does

  • Sends a page view on load and on every client-side navigation (pushState, replaceState and the back button), once per path. Only the path is sent, with IDs and email addresses masked, never the query string.
  • Records the referring site's hostname and utm_source / utm_campaign from the landing URL.
  • Sends with navigator.sendBeacon, so it never delays page unloads, and falls back to fetch.
  • Never runs on localhost, 127.*, *.local or file: pages.
  • Exposes window.mosaicdeck.track() for custom events.

Private IDs and email addresses in URLs

IDs in a page's path never leave the visitor's browser. Before anything is sent, each path segment that looks like an ID is replaced with :id: /gigs/0b5e9f6c-4a3d-4c1e-9f7a-2d8b6c4e1a90 arrives as /gigs/:id, and all those pages add up to one row in your top pages. A segment counts as an ID when it is:

  • a UUID, of any version, in upper or lower case;
  • a ULID (26 Crockford base32 characters);
  • 16 or more hex characters;
  • 6 or more digits, so dates like /2026/10/07 and short numbers stay;
  • a token of 16 or more letters, digits, _ and - with at least one digit, one uppercase and one lowercase letter. Lowercase slugs like /recipes/banana-bread-2024 are never masked.

Email addresses in a page's path never leave the browser either. Many sites serve their app for any URL, so a visitor who pastes an address into the address bar, or follows a broken link, would otherwise send it as the path. Instead, a path segment with an email address in it is sent as :email: /alice@example.com arrives as /:email, and /u/alice%40example.com/settings as /u/:email/settings. An address is anything shaped like name@domain.tld, with no spaces, slashes or second @ in it, and a domain whose last part doesn't start with a digit. Handles like /@alice, /user@host without a dot and versions like lodash@4.17.21 stay. This is always on, even with data-mask-ids="false": an email address is personal data, not an ID.

Short codes such as /challenge/ABC123 look like ordinary words, so list the routes that carry them in the tag's data-mask attribute: paths separated by spaces or commas, where each * stands for exactly one segment.

<script>window.mosaicdeck=window.mosaicdeck||{q:[],track:function(){var q=window.mosaicdeck.q;q.length<50&&q.push([].slice.call(arguments))}}</script>
<script defer src="https://t.mosaicdeck.com/s.js" data-site="site_01J9XEXAMPLE00000000000000" data-mask="/challenge/* /join/*"></script>

A pattern applies to every path that starts with it, segment by segment, and only its * segments change. With the tag above, /challenge/ABC123 is sent as /challenge/*, and its result page at /challenge/ABC123/result as /challenge/*/result. Other paths stay as they are, including /challenges/ABC123 and /challenge/. Several patterns can apply to one path, and the ID and email masking above still covers its other segments. A pattern that doesn't start with /, or that puts * inside a longer segment, is ignored.

Custom events and errors carry the same masked path. An error's file (for an error in an inline script, that's the page's URL) and any URLs in its message are masked the same way, and lose their query string and fragment; any other email address in the message becomes :email too. Event properties are sent as you set them, so don't copy URLs or email addresses into them. To send IDs as they are, add data-mask-ids="false" to the tag; email addresses stay masked, and your data-mask patterns still apply.

Allowed domains

Events are only accepted from the hostnames listed for your project. A page on a hostname that another project in the same workspace lists counts for that project instead, even with this project's tag: a staging copy built with your production tag counts for a staging project that lists its hostname, with no change to your code. Exactly one active website project has to list the hostname; with none, or more than one, events from it are dropped.

If data arrives from a domain no project lists (a www variant, a staging copy), the Connections tab shows “Receiving data from an unlisted domain”, so you can add it to the project's hostnames or add it as a project of its own.

Content Security Policy

If your site sends a CSP, allow the script and its beacons: add https://t.mosaicdeck.com toscript-src and connect-src. With the proxy below, only script-src needs it. The inline stub also needs to be allowed: add its hash, 'sha256-nmZrZlSuPHpostREZGSXAgxaYR/OCW1xa5Fq/T8HR7Y=', to script-src (or use a nonce). The hash only matches the stub exactly as shown, so paste it unchanged.

Framework guides

Plain HTML

Paste the snippet inside <head> on every page, or in your shared header template.

<head>
  <script>window.mosaicdeck=window.mosaicdeck||{q:[],track:function(){var q=window.mosaicdeck.q;q.length<50&&q.push([].slice.call(arguments))}}</script>
<script defer src="https://t.mosaicdeck.com/s.js" data-site="site_01J9XEXAMPLE00000000000000"></script>
</head>

Next.js

Use next/script. Client-side navigations are picked up automatically.

// app/layout.tsx (App Router). For the Pages Router, put the <Script> in pages/_app.tsx and the stub
// <script> in the <Head> of pages/_document.tsx (see below).
import Script from "next/script";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        {/* The queue stub, as plain inline text so it matches the CSP hash */}
        <script dangerouslySetInnerHTML={{ __html: "window.mosaicdeck=window.mosaicdeck||{q:[],track:function(){var q=window.mosaicdeck.q;q.length<50&&q.push([].slice.call(arguments))}}" }} />
      </head>
      <body>
        {children}
        <Script src="https://t.mosaicdeck.com/s.js" data-site="site_01J9XEXAMPLE00000000000000" strategy="afterInteractive" />
      </body>
    </html>
  );
}

Pages Router: put the stub in pages/_document.tsx and the <Script> tag inpages/_app.tsx.

// pages/_document.tsx (Pages Router): the stub, as plain inline text so it matches the CSP hash
import { Html, Head, Main, NextScript } from "next/document";

export default function Document() {
  return (
    <Html lang="en">
      <Head>
        <script dangerouslySetInnerHTML={{ __html: "window.mosaicdeck=window.mosaicdeck||{q:[],track:function(){var q=window.mosaicdeck.q;q.length<50&&q.push([].slice.call(arguments))}}" }} />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

Astro

---
// src/layouts/Layout.astro
---
<head>
  <!-- is:inline tells Astro to leave the tag exactly as written -->
  <script is:inline>window.mosaicdeck=window.mosaicdeck||{q:[],track:function(){var q=window.mosaicdeck.q;q.length<50&&q.push([].slice.call(arguments))}}</script>
  <script is:inline defer src="https://t.mosaicdeck.com/s.js" data-site="site_01J9XEXAMPLE00000000000000"></script>
</head>

WordPress

Paste the snippet into your theme's header through a “header and footer code” plugin, or add it from a child theme:

// In your child theme's functions.php, or a code-snippets plugin
add_action('wp_head', function () {
    echo '<script>window.mosaicdeck=window.mosaicdeck||{q:[],track:function(){var q=window.mosaicdeck.q;q.length<50&&q.push([].slice.call(arguments))}}</script>';
    echo '<script defer src="https://t.mosaicdeck.com/s.js" data-site="site_01J9XEXAMPLE00000000000000"></script>';
});

React and other single-page apps

Add the snippet to index.html. Route changes made with the History API (React Router, TanStack Router, Vue Router and similar) are tracked as page views with no extra code.

<!-- index.html (Vite, Create React App, Vue, Svelte, …) -->
<head>
  <script>window.mosaicdeck=window.mosaicdeck||{q:[],track:function(){var q=window.mosaicdeck.q;q.length<50&&q.push([].slice.call(arguments))}}</script>
<script defer src="https://t.mosaicdeck.com/s.js" data-site="site_01J9XEXAMPLE00000000000000"></script>
</head>

Webflow, Squarespace and site builders

Paste the snippet into the site-wide custom code (head) setting.

First-party proxy

Some ad blockers block third-party analytics. You can send events through your own domain instead: the script posts to your proxy, and the proxy forwards the request to Mosaicdeck.

A proxy hides your visitors' real IP addresses, which Mosaicdeck needs (in memory only) for rate limits, countries and the daily visitor hash. So the proxy passes them on with a proxy token that proves the request came from you:

  1. On the site's Connections tab, create a proxy token. It's shown once. Store it as a secret namedMOSAICDECK_PROXY_TOKEN on your host.
  2. Deploy one of the proxies below. Each forwards the body unchanged, plus the visitor's User-Agent and these headers:
    • X-Mosaicdeck-Proxy-Token: the proxy token
    • X-Mosaicdeck-Client-IP: the visitor's IP address
    • X-Mosaicdeck-Country: the visitor's country code, if your host knows it
    • X-Mosaicdeck-Origin: the visitor's Origin
  3. Point the script at your proxy with data-api. The script adds /e to it:
    <script>window.mosaicdeck=window.mosaicdeck||{q:[],track:function(){var q=window.mosaicdeck.q;q.length<50&&q.push([].slice.call(arguments))}}</script>
    <script defer src="https://t.mosaicdeck.com/s.js" data-site="site_01J9XEXAMPLE00000000000000" data-api="https://example.com/relay"></script>

Without a valid token, every visitor behind the proxy looks like one person: visitor counts will be wrong and the per-visitor rate limit applies to all of them together. The Connections tab warns you if it sees proxy headers without a valid token.

The token is checked against the project named by the tag. So a staging copy built with your production tag forwards with the production token too, and its events still count for the staging project that lists its hostname (seeallowed domains).

Cloudflare Workers

Set the secret with wrangler secret put MOSAICDECK_PROXY_TOKEN and add a route forexample.com/relay/*.

// Cloudflare Worker on your own domain, routed to example.com/relay/*
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (request.method !== "POST" || url.pathname !== "/relay/e") {
      return new Response("Not found", { status: 404 });
    }
    const headers = {
      "Content-Type": "text/plain",
      "User-Agent": request.headers.get("User-Agent") ?? "",
      "X-Mosaicdeck-Proxy-Token": env.MOSAICDECK_PROXY_TOKEN,
      "X-Mosaicdeck-Client-IP": request.headers.get("CF-Connecting-IP") ?? "",
      "X-Mosaicdeck-Origin": request.headers.get("Origin") ?? url.origin,
    };
    if (request.cf?.country) headers["X-Mosaicdeck-Country"] = request.cf.country;
    const res = await fetch("https://t.mosaicdeck.com/e", { method: "POST", headers, body: await request.text() });
    return new Response(null, { status: res.status });
  },
};

Next.js (route handler)

Works on Vercel, Netlify, Cloudflare and self-hosted Node. Add the token as an environment variable.

// app/relay/e/route.ts (Next.js App Router, any host)
export async function POST(request: Request) {
  const h = request.headers;
  const headers: Record<string, string> = {
    "Content-Type": "text/plain",
    "User-Agent": h.get("user-agent") ?? "",
    "X-Mosaicdeck-Proxy-Token": process.env.MOSAICDECK_PROXY_TOKEN!,
    "X-Mosaicdeck-Client-IP": (h.get("x-forwarded-for") ?? "").split(",")[0].trim() || (h.get("x-real-ip") ?? ""),
    "X-Mosaicdeck-Origin": h.get("origin") ?? new URL(request.url).origin,
  };
  const country = h.get("x-vercel-ip-country") ?? h.get("cf-ipcountry");
  if (country) headers["X-Mosaicdeck-Country"] = country;
  const res = await fetch("https://t.mosaicdeck.com/e", { method: "POST", headers, body: await request.text() });
  return new Response(null, { status: res.status });
}

Vercel Functions (without Next.js)

This file serves /api/relay/e, so use data-api="https://example.com/api/relay".

// api/relay/e.ts (Vercel Functions without Next.js)
export async function POST(request: Request) {
  const h = request.headers;
  const headers: Record<string, string> = {
    "Content-Type": "text/plain",
    "User-Agent": h.get("user-agent") ?? "",
    "X-Mosaicdeck-Proxy-Token": process.env.MOSAICDECK_PROXY_TOKEN!,
    "X-Mosaicdeck-Client-IP": (h.get("x-forwarded-for") ?? "").split(",")[0].trim(),
    "X-Mosaicdeck-Origin": h.get("origin") ?? new URL(request.url).origin,
  };
  const country = h.get("x-vercel-ip-country");
  if (country) headers["X-Mosaicdeck-Country"] = country;
  const res = await fetch("https://t.mosaicdeck.com/e", { method: "POST", headers, body: await request.text() });
  return new Response(null, { status: res.status });
}

Netlify Edge Functions

// netlify/edge-functions/relay.ts (Netlify Edge Functions)
import type { Config, Context } from "@netlify/edge-functions";

export default async (request: Request, context: Context) => {
  if (request.method !== "POST") return new Response(null, { status: 405 });
  const headers: Record<string, string> = {
    "Content-Type": "text/plain",
    "User-Agent": request.headers.get("user-agent") ?? "",
    "X-Mosaicdeck-Proxy-Token": Netlify.env.get("MOSAICDECK_PROXY_TOKEN") ?? "",
    "X-Mosaicdeck-Client-IP": context.ip,
    "X-Mosaicdeck-Origin": request.headers.get("origin") ?? new URL(request.url).origin,
  };
  if (context.geo?.country?.code) headers["X-Mosaicdeck-Country"] = context.geo.country.code;
  const res = await fetch("https://t.mosaicdeck.com/e", { method: "POST", headers, body: await request.text() });
  return new Response(null, { status: res.status });
};

export const config: Config = { path: "/relay/e" };

Troubleshooting

  • No data? The Connections tab says whether the tag was found on your homepage: "Tag found, waiting for the first visit" means it's installed and nobody has visited since. Choose Check again after you deploy it. A tag added from JavaScript or by a tag manager doesn't show there, and the first visit still connects it. Also check that the page isn't on localhost, that the site's hostname is listed in site settings, and that an ad blocker isn't blocking the script (try the proxy).
  • Counts look low? Bots and requests without a browser user agent are filtered out.
  • Seeing an unlisted domain warning? Add the hostname in the project's settings, or add it as its own project (a staging copy, say) so its events count there. Ignore it if it isn't yours.