Analytics
Track which content your users view, in which language, and from where — with sendBeacon-first transport and framework adapters
The @better-i18n/content SDK is a lightweight tracking client for Better i18n Content. Drop it into any frontend, call track() or useTrackView(), and your dashboard fills with views, languages, models, and countries — automatically.
What gets tracked
By default, only what you explicitly track. There's no auto-pageview, no fingerprinting, no cookies.
Every track() call sends one event with:
- Project + organization context (resolved from your API key)
- Event name (e.g.
content.view) - Custom properties you pass (
entryId,language,framework, ...) - Country code (from Cloudflare's edge metadata — never IP)
- Timestamp
See Data Model for the full schema.
Configure
You need two values from the dashboard:
| Value | Where to find it |
|---|---|
| Project ID | Settings → General → Project ID (format: your-org/your-project) |
| API Key | Settings → API Keys → Public Key (starts with bi_pub_) |
The Project ID is the same org/project slug shown in your dashboard URL — dash.better-i18n.com/your-org/your-project → "your-org/your-project". Pass this exact string to the SDK as projectId. The Content SDK and Analytics SDK both accept this format.
Set them in your .env.local:
NEXT_PUBLIC_BETTER_I18N_PROJECT_ID=your-org/your-project
NEXT_PUBLIC_BETTER_I18N_KEY=bi_pub_xxxxxInstall
npm install @better-i18n/contentbun add @better-i18n/contentpnpm add @better-i18n/contentyarn add @better-i18n/contentQuick start (Next.js)
Wrap your app
'use client'
import { ContentProvider } from '@better-i18n/content/adapters/nextjs'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ContentProvider
config={{
projectId: process.env.NEXT_PUBLIC_BETTER_I18N_PROJECT_ID!,
apiKey: process.env.NEXT_PUBLIC_BETTER_I18N_KEY!,
}}
>
{children}
</ContentProvider>
)
}Track views on content pages
'use client'
import { useTrackView } from '@better-i18n/content/adapters/nextjs'
export default function BlogPost({ post }: { post: Post }) {
useTrackView('content.view', {
entryId: post.id,
contentModel: 'blog',
entrySlug: post.slug,
language: post.locale,
})
return <article>{post.body}</article>
}See the data
Open your project in the dashboard — within seconds, the Content Analytics section fills in. Total views, top entries, language breakdown, country map.
Why a public API key is safe
The SDK uses your bi_pub_* key — designed to be embedded in client-side code. Track events are write-only:
- Can't read content data
- Can't read other projects' analytics
- Server-side rate-limited per IP and per project
This is the same model PostHog, Mixpanel, and Vercel Analytics use. Stolen keys can spam your events, but they can't exfiltrate anything.
See Why is my API key safe? below for the threat model.
Transport: never block your page
The SDK tries three transports in order and silently drops failures:
sendBeacon → fetch(keepalive) → fetch
│ │ │
│ └─ < 51.2 KB body └─ regular POST
└─ fire-and-forget at page unloadsendBeacon survives navigation and tab close — events never get lost when users leave. fetch keepalive handles the rest under the body limit. Plain fetch is the last resort.
All three are wrapped in try/catch. A failed event is dropped silently — never thrown to your app.
SSR & build-time safety
The SDK has three guards:
isBuildTime()— DetectsNEXT_PHASE=phase-production-build,GATSBY_BUILD_STAGE,ASTRO_BUILD. Calls during build log a warning and skip.isBrowser()— Calls from a server (Node.js, edge runtime) skip unlessoptions.allowServer: true.- Synchronous init —
ContentProvidercreates the tracker synchronously during the first render. The tracker itself is SSR-safe:track()checksisBrowser()internally and becomes a no-op on the server.
You can call track() in any render path — it's safe.
Graceful degradation
If useTrackView() or useContent() is called outside of a ContentProvider, the SDK logs a one-time warning and silently disables tracking — your app never crashes.
[better-i18n/content] useContent called outside ContentProvider. Tracking will be disabled.This matches the behavior of PostHog, Segment, and other production analytics SDKs.
Other frameworks
import { ContentProvider, useTrackView } from '@better-i18n/content/adapters/react'
<ContentProvider config={{ projectId, apiKey }}>
<App />
</ContentProvider>import { ContentProvider, useTrackView } from '@better-i18n/content/adapters/expo'
// Auto-flushes when app goes to background via AppState
<ContentProvider config={{ projectId, apiKey }}>
<App />
</ContentProvider>Note: Expo uses fetch (no sendBeacon on React Native).
import { provideContent, useTrack } from '@better-i18n/content/adapters/vue'
// In your root component
provideContent({ projectId, apiKey })
// In any child component
const track = useTrack()
track('content.view', { entryId: 'abc' })import { initContent, track } from '@better-i18n/content/adapters/svelte'
// On app load
initContent({ projectId, apiKey })
// Anywhere
track('content.view', { entryId: 'abc' })<script type="module">
import { init, track } from 'https://esm.sh/@better-i18n/content/adapters/vanilla'
init({ projectId: '...', apiKey: '...' })
track('content.view', { entryId: 'abc' })
</script>Or via window.betterContent.track(...) after init().
Property validation
Properties must be primitive: string, number, boolean, or null. Nested objects and arrays are rejected:
- Development (
NODE_ENV !== 'production'): throwsError("Invalid property...") - Production: silently strips the invalid property and warns in debug mode
This catches bugs early without breaking your app in prod.
// ✅ OK
track('content.view', { entryId: 'abc', count: 1, featured: true })
// ❌ Throws in dev, stripped in prod
track('content.view', { entryId: 'abc', author: { id: '1' } })Why is my API key safe?
| Concern | Mitigation |
|---|---|
| Key extracted from JS bundle | Key is write-only — can't read content or analytics |
| Spam events from stolen key | Server-side rate limit (per IP, per project) + CF WAF |
| Cross-project access | Key scoped to a single projectId at validation time |
| Sensitive PII in properties | You control payload — never log emails, only IDs |
Phase 1 mitigations: KV-cached key validation (1h TTL), CF Workers CPU/memory limits, server-side validation rejects malformed payloads.
Proxy to avoid ad blockers
Ad blockers may block requests to content.better-i18n.com. Proxy the track endpoint through your own domain so the browser only sees first-party requests.
The simplest option for Next.js apps. Add a rewrite rule in next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{
source: '/api/ca/:path*',
destination: 'https://content.better-i18n.com/:path*',
},
]
},
}
export default nextConfigThen point the SDK to your proxy:
<ContentProvider
config={{
projectId: process.env.NEXT_PUBLIC_BETTER_I18N_PROJECT_ID!,
apiKey: process.env.NEXT_PUBLIC_BETTER_I18N_KEY!,
analytics: {
endpoint: '/api/ca/v1/track',
},
}}
>If rewrites cause issues on your host, use a middleware (Next.js 16: proxy.js, 15 and earlier: middleware.js):
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api/ca/')) {
const url = new URL(
request.nextUrl.pathname.replace('/api/ca', ''),
'https://content.better-i18n.com',
)
url.search = request.nextUrl.search
return NextResponse.rewrite(url)
}
}
export const config = {
matcher: '/api/ca/:path*',
}Zero egress fees and 100K free requests/day. Deploy a Worker on your domain:
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url)
if (!url.pathname.startsWith('/api/ca/')) {
return new Response('Not found', { status: 404 })
}
const target = new URL(
url.pathname.replace('/api/ca', ''),
'https://content.better-i18n.com',
)
target.search = url.search
const headers = new Headers(request.headers)
headers.set('host', 'content.better-i18n.com')
return fetch(target.toString(), {
method: request.method,
headers,
body: request.body,
})
},
}Add a route in your wrangler.toml matching /api/ca/* on your domain.
Nginx:
location /api/ca/ {
proxy_pass https://content.better-i18n.com/;
proxy_set_header Host content.better-i18n.com;
proxy_ssl_server_name on;
}Caddy:
handle_path /api/ca/* {
reverse_proxy https://content.better-i18n.com {
header_up Host content.better-i18n.com
}
}Pick any path you like — /api/ca/ is just a suggestion. Avoid obvious names like /api/analytics or /api/track that ad blockers might pattern-match.
Next steps
- API Reference — full surface area:
track(),useTrackView(), options - Data Model — blob/double mapping, what's stored where