takt

Astro

Astro has a dedicated integration: @vskstudio/takt-astro, built on the @vskstudio/takt-core core. It injects a tiny browser runtime that boots Takt, fires the initial pageview and tracks client-side navigations, including Astro View Transitions.

pnpm add @vskstudio/takt-astro @vskstudio/takt-core

@vskstudio/takt-core (>=0.10.0 since 0.8.0) and astro (>=4) are peer dependencies. The runtime is SSR / prerender safe: it sits behind a typeof window guard and only runs in the browser.

This page is the option reference. For the end-to-end integration and its main trap, the extra astro:page-load listener that doubles every pageview, see the guide cookieless analytics on an Astro site.

Pick one of the two paths below, not both. Both boot core's default instance behind the same window.__takt flag: whichever loads second re-initialises nothing, it does strictly nothing. So you would not double-count. You would just ship a second script for no reason.

Add takt() to your Astro config integrations list:

// astro.config.mjs
import { defineConfig } from 'astro/config'
import takt from '@vskstudio/takt-astro'

export default defineConfig({
  integrations: [takt({ domain: 'example.com' })]
})

<Takt /> component

For per-layout control, place the component in a layout <head>:

---
import Takt from '@vskstudio/takt-astro/Takt.astro'
---
<head>
  <Takt domain="example.com" />
</head>

The integration and the component accept the same options, with one exception: scrubUrl is a function and is accepted only by the integration (see the note under the table).

OptionTypeDefaultRole
domainstringlocation.hostnameSite identifier
endpointstringhttps://taktlytics.com/api/eventIngestion URL; set /api/event for a same-origin first-party proxy
scriptOriginstringnoneOrigin to derive the endpoint from ({origin}/api/event), for a proxied custom domain; endpoint still wins
outboundbooleanfalseTracks outbound links
filesbooleanfalseTracks downloads
spabooleantrueTracks client navigation (maps to core’s auto option)
track404booleanfalseReports a 404 event on error pages ([data-takt-404] / <meta name="takt:404"> marker, or a 404 HTTP status).
taggedbooleanfalseTracks elements carrying data-takt-event
respectDntbooleantrueRespects Do Not Track
excludeLocalhostbooleantrueIgnores localhost / private IPs
enabledbooleantrueMaster kill switch: set to false and no event is sent
sampleRatenumber1Fraction of visitors tracked, from 0 to 1
trackQuerybooleanfalseSends the full URL instead of stripping query and hash
queryParamsstring[]noneWhitelist of params kept when trackQuery is off
excludestring[]nonePath prefixes never tracked, e.g. ['/app','/account'] (segment-bounded: /app covers /app and /app/…, not /application)
scrubUrl(url: string) => stringnoneIntegration only. Rewrites each URL before it is sent (page, referrer, url of outbound links and downloads, path of 404s)
debugbooleanfalseLogs each payload to the browser console before sending
redactRoutesstring[]noneRoute patterns sent in place of the real path, e.g. ['/verify/[token]'] sends /verify/[token] instead of /verify/abc123 (see Route redaction)
redactUndecodablePathsbooleantrueNot exposed by this wrapper: set it on the core SDK (init() / createTakt()), see Unreadable paths
routeTemplatesbooleanfalseSends every page as its Astro route template (/blog/[slug]), read from Astro.routePattern. Needs Astro 5 and the route meta tag
scrubUrl is a function: it is serialised at build time via toString() then re-evaluated in the browser. It must therefore be self-contained: no closure variables, no outer-scope references, and never built from user input. The <Takt /> component serialises its config as a JSON data island, which cannot carry a function: passing scrubUrl to it throws at build time rather than silently doing nothing.

Route redaction

The query string is stripped by default, but the path is sent as is, so /verify/abc123 leaks the token. List the sensitive routes in redactRoutes and every matching path is sent as its pattern, while the other pages keep their real path:

takt({ redactRoutes: ['/verify/[token]', '/invoices/[id].pdf'] })

The rule covers the page URL, same-site referrers, outbound and download link destinations, and 404 paths. Patterns use Astro’s syntax ([param], [...rest]) and also accept :param and *.

To send every page as its route template instead (/blog/hello becomes /blog/[slug]), turn on routeTemplates. Astro knows the template on the server (Astro.routePattern), so it travels in a <meta name="takt:route"> tag that the runtime reads at each pageview, including after a ClientRouter navigation. With the integration, render that tag with <TaktRoute /> in the <head> of your layout:

// astro.config.mjs
export default defineConfig({
  integrations: [takt({ routeTemplates: true })]
})
---
import TaktRoute from '@vskstudio/takt-astro/TaktRoute.astro'
---
<head>
  <TaktRoute />
</head>

The <Takt /> component renders the tag itself when routeTemplates is set:

<Takt domain="example.com" routeTemplates />

Astro.routePattern exists from Astro 5. On Astro 4 no tag is rendered, so redactRoutes still applies and the real path is sent otherwise. This mode merges every article of a public blog into one row, so it suits private areas better; redactRoutes is usually the right tool for a public site. Details in Privacy.

View Transitions

Astro’s ClientRouter runs several history operations per navigation (a scroll-restoration replaceState, then a pushState), so core’s history patch would over-count. The integration rules it out: it boots core with auto: false, which disables both the pushState/replaceState patch and the boot pageview.

The runtime drives pageviews from Astro’s own lifecycle instead:

  • one explicit initial pageview when the script boots, which also covers plain MPA sites, where the script re-runs on every page load and astro:after-swap never fires;
  • then one pageview per astro:after-swap event, which fires exactly once per ClientRouter navigation (View Transitions DOM swaps and back/forward included), whenever spa is on.

Each navigation is therefore counted exactly once.

Do not add an astro:page-load listener to track pages: the runtime already registers its own astro:after-swap. Yours would install a second one and count every navigation twice.
Corollary: because core's history patch is off, a history.pushState you drive by hand (filters, tabs, pagination outside the ClientRouter) fires no astro:after-swap, hence no pageview. That is the one case where calling pageview() yourself is the right answer.

Custom events

The core functions are re-exported for convenience:

import { track } from '@vskstudio/takt-astro'

track('Signup', { revenue: { amount: '9.00', currency: 'USD' } })

track/pageview/optOut/optIn/isOptedOut run in the browser: call them from a client <script> or a hydrated UI-framework component.

optOut, optIn and isOptedOut do not wait for the runtime to boot: they read and write the stored choice directly. A consent banner can therefore run before the injected runtime, and the instance it boots later honours the choice. Call them from a client <script>, since the choice lives in the browser.

<button id="analytics-toggle"></button>
<script>
  import { isOptedOut, optIn, optOut } from '@vskstudio/takt-astro'

  const button = document.getElementById('analytics-toggle')
  const render = () => {
    if (button) button.textContent = isOptedOut() ? 'Enable analytics' : 'Disable analytics'
  }
  button?.addEventListener('click', () => {
    if (isOptedOut()) optIn()
    else optOut()
    render()
  })
  render()
</script>