Overview
Plausible Analytics supports both client-side (JavaScript) and server-side event tracking, each with distinct advantages and use cases. Understanding when to use each approach is crucial for building a reliable analytics implementation that captures all important user interactions.
Client-side tracking is Plausible's default and most common implementation method, utilizing JavaScript in the browser to capture user interactions. Server-side tracking involves sending events from your backend servers directly to Plausible's API, bypassing the browser entirely.
Key Differences
Client-Side Tracking:
- Events originate from the user's browser
- Automatically captures browser metadata (user agent, screen size, referrer)
- Subject to ad blockers and browser privacy features
- No server infrastructure required beyond hosting static files
- Real-time user context available
Server-Side Tracking:
- Events originate from your backend servers
- Immune to ad blockers and browser-based blocking
- Requires manual IP address and user agent forwarding
- Guaranteed event delivery for critical conversions
- Better for sensitive operations or high-value transactions
Architecture Considerations
The choice between client-side and server-side tracking (or a hybrid approach) depends on:
- Data Completeness Requirements: How critical is capturing 100% of events?
- Technical Infrastructure: What server-side capabilities do you have?
- Privacy Constraints: Are there consent requirements affecting client-side tracking?
- Event Types: Are events user-initiated (clicks) or system-initiated (background processes)?
- Reliability Needs: Can you tolerate some data loss from ad blockers?
Most implementations use a hybrid approach: client-side for standard pageviews and interactions, server-side for critical conversions.
When to Use Client-Side Collection
Client-side tracking should be your default choice for most analytics needs. It's simpler to implement, requires no backend changes, and automatically captures important user context.
Ideal Use Cases
- Pageview Tracking: The most common analytics use case
- User Interaction Events: Clicks, form interactions, video plays
- Content Engagement: Scroll depth, time on page, article reads
- Navigation Tracking: Site search, menu clicks, filters
- Initial Implementation: Starting with Plausible for the first time
- Marketing Sites: Brochure sites, blogs, landing pages
- Front-End Heavy Applications: SPAs, progressive web apps
Advantages of Client-Side Tracking
Automatic Context Collection:
// All of this is captured automatically:
// - User agent (browser, device, OS)
// - Screen dimensions
// - Referrer URL
// - Current page URL
// - User's country (from IP, not stored)
// - Campaign parameters (UTM tags)
plausible('pageview'); // That's it!
Ease of Implementation:
<!-- Single script tag -->
<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>
Real-Time User Context:
// Access to DOM, window, document
document.querySelector('#signup-btn').addEventListener('click', function() {
plausible('Signup', {
props: {
button_text: this.textContent,
page_section: this.closest('section').id
}
});
});
Limitations of Client-Side Tracking
- Ad Blocker Impact: 10-30% of users may have ad blockers that prevent tracking
- Browser Privacy Features: ITP, ETP, and other privacy features may limit tracking
- User Control: Users can disable JavaScript or clear cookies
- Network Dependency: Events may fail if user loses connection before event sends
- Timing Issues: Critical events during page transitions may be lost
Client-Side Implementation Examples
Basic Setup:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>My Site</title>
<!-- Plausible client-side tracking -->
<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>
</head>
<body>
<button id="cta">Sign Up</button>
<script>
document.getElementById('cta').addEventListener('click', function() {
plausible('CTA Click');
});
</script>
</body>
</html>
Single-Page Application:
// React with React Router
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export function useAnalytics() {
const location = useLocation();
useEffect(() => {
plausible('pageview');
}, [location]);
}
// Usage in App component
function App() {
useAnalytics();
return <Routes>...</Routes>;
}
E-commerce Client-Side Tracking:
// Product page tracking
function trackProductView(product) {
plausible('Product View', {
props: {
product_id: product.id,
category: product.category,
price_range: getPriceRange(product.price)
}
});
}
// Add to cart
function trackAddToCart(product) {
plausible('Add to Cart', {
props: {
product_id: product.id,
quantity: product.quantity.toString()
}
});
}
When to Use Server-Side Collection
Server-side tracking should be used when event reliability is critical and you cannot tolerate data loss from client-side blocking mechanisms.
Ideal Use Cases
- Critical Conversions: Purchase completions, subscription activations
- Financial Transactions: Payment confirmations, refunds, chargebacks
- Account Operations: User registrations, account upgrades/downgrades
- API-Driven Events: Webhooks, background jobs, scheduled tasks
- Server-Side Rendering (SSR): Next.js, Nuxt.js, or other SSR frameworks
- High-Value Actions: Actions worth significant revenue or business value
- Compliance Requirements: When client-side tracking is restricted by consent laws
Advantages of Server-Side Tracking
Guaranteed Delivery:
// No ad blockers, no browser restrictions
// If your server sends it, Plausible receives it
await sendServerSideEvent('Purchase', { order_id: '12345' });
Authoritative Data:
// Server-side data is the source of truth
// No client-side manipulation possible
const actualRevenue = order.totalAfterRefunds;
await trackPurchase(order.id, actualRevenue);
Background Events:
// Track events that happen outside user sessions
async function processSubscriptionRenewal(subscription) {
await chargeCustomer(subscription);
// Track renewal server-side
await plausibleServerSide('Subscription Renewal', {
revenue: { currency: 'USD', amount: subscription.price },
props: { plan: subscription.planName }
});
}
Limitations of Server-Side Tracking
- Manual Context: Must manually forward IP address, user agent, etc.
- Development Effort: Requires backend code changes
- IP Address Handling: Privacy concerns with IP forwarding
- User Agent Forwarding: Must capture and forward browser information
- Deduplication Complexity: Risk of double-counting with client-side events
Server-Side Implementation Examples
Node.js Implementation:
const axios = require('axios');
async function trackServerSideEvent(eventName, options = {}) {
const {
domain = 'example.com',
url,
referrer = null,
userAgent,
ip,
props = {},
revenue = null
} = options;
const payload = {
name: eventName,
url: url,
domain: domain,
...(referrer && { referrer }),
...(props && Object.keys(props).length > 0 && {
props: JSON.stringify(props)
}),
...(revenue && {
revenue: JSON.stringify(revenue)
})
};
const headers = {
'User-Agent': userAgent,
'X-Forwarded-For': ip,
'Content-Type': 'application/json'
};
try {
await axios.post('https://plausible.io/api/event', payload, { headers });
console.log(`Event tracked: ${eventName}`);
} catch (error) {
console.error('Failed to track event:', error.message);
}
}
// Usage in Express route
app.post('/checkout/complete', async (req, res) => {
const order = await processOrder(req.body);
// Track purchase server-side
await trackServerSideEvent('Purchase', {
url: `${req.protocol}://${req.get('host')}/checkout/complete`,
userAgent: req.headers['user-agent'],
ip: req.ip,
revenue: {
currency: order.currency,
amount: order.total
},
props: {
order_id: order.id,
payment_method: order.paymentMethod
}
});
res.json({ success: true, orderId: order.id });
});
Python Implementation:
import requests
import json
def track_server_side_event(
event_name,
domain='example.com',
url='',
user_agent='',
ip='',
referrer=None,
props=None,
revenue=None
):
"""Track event server-side to Plausible"""
payload = {
'name': event_name,
'url': url,
'domain': domain
}
if referrer:
payload['referrer'] = referrer
if props:
payload['props'] = json.dumps(props)
if revenue:
payload['revenue'] = json.dumps(revenue)
headers = {
'User-Agent': user_agent,
'X-Forwarded-For': ip,
'Content-Type': 'application/json'
}
try:
response = requests.post(
'https://plausible.io/api/event',
json=payload,
headers=headers,
timeout=5
)
response.raise_for_status()
print(f'Event tracked: {event_name}')
except requests.exceptions.RequestException as e:
print(f'Failed to track event: {e}')
# Usage in Django view
from django.http import JsonResponse
from django.views.decorators.http import require_POST
@require_POST
def complete_signup(request):
user = create_user(request.POST)
# Track signup server-side
track_server_side_event(
event_name='Signup',
url=request.build_absolute_uri(),
user_agent=request.META.get('HTTP_USER_AGENT', ''),
ip=get_client_ip(request),
props={
'signup_method': request.POST.get('method'),
'plan': request.POST.get('plan')
}
)
return JsonResponse({'success': True, 'user_id': user.id})
def get_client_ip(request):
"""Extract client IP from request"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
PHP Implementation:
<?php
function trackServerSideEvent($eventName, $options = []) {
$domain = $options['domain'] ?? 'example.com';
$url = $options['url'] ?? '';
$userAgent = $options['userAgent'] ?? '';
$ip = $options['ip'] ?? '';
$referrer = $options['referrer'] ?? null;
$props = $options['props'] ?? [];
$revenue = $options['revenue'] ?? null;
$payload = [
'name' => $eventName,
'url' => $url,
'domain' => $domain
];
if ($referrer) {
$payload['referrer'] = $referrer;
}
if (!empty($props)) {
$payload['props'] = json_encode($props);
}
if ($revenue) {
$payload['revenue'] = json_encode($revenue);
}
$headers = [
'User-Agent: ' . $userAgent,
'X-Forwarded-For: ' . $ip,
'Content-Type: application/json'
];
$ch = curl_init('https://plausible.io/api/event');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 200 && $httpCode < 300) {
error_log("Event tracked: $eventName");
} else {
error_log("Failed to track event: $eventName (HTTP $httpCode)");
}
}
// Usage in checkout completion
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $_POST['action'] === 'complete_purchase') {
$order = processOrder($_POST);
// Track purchase server-side
trackServerSideEvent('Purchase', [
'url' => 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'],
'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'ip' => $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'],
'revenue' => [
'currency' => $order['currency'],
'amount' => $order['total']
],
'props' => [
'order_id' => $order['id'],
'payment_method' => $order['payment_method']
]
]);
echo json_encode(['success' => true, 'order_id' => $order['id']]);
}
?>
Ruby/Rails Implementation:
require 'net/http'
require 'json'
class PlausibleServerSide
API_ENDPOINT = 'https://plausible.io/api/event'
def self.track_event(event_name, options = {})
domain = options[:domain] || 'example.com'
url = options[:url] || ''
user_agent = options[:user_agent] || ''
ip = options[:ip] || ''
referrer = options[:referrer]
props = options[:props] || {}
revenue = options[:revenue]
payload = {
name: event_name,
url: url,
domain: domain
}
payload[:referrer] = referrer if referrer
payload[:props] = props.to_json if props.any?
payload[:revenue] = revenue.to_json if revenue
uri = URI(API_ENDPOINT)
request = Net::HTTP::Post.new(uri)
request['User-Agent'] = user_agent
request['X-Forwarded-For'] = ip
request['Content-Type'] = 'application/json'
request.body = payload.to_json
begin
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(request)
end
if response.is_a?(Net::HTTPSuccess)
Rails.logger.info "Event tracked: #{event_name}"
else
Rails.logger.error "Failed to track event: #{event_name} (#{response.code})"
end
rescue StandardError => e
Rails.logger.error "Error tracking event: #{e.message}"
end
end
end
# Usage in Rails controller
class CheckoutController < ApplicationController
def complete
@order = process_order(params)
# Track purchase server-side
PlausibleServerSide.track_event('Purchase',
url: request.original_url,
user_agent: request.user_agent,
ip: request.remote_ip,
revenue: {
currency: @order.currency,
amount: @order.total
},
props: {
order_id: @order.id,
payment_method: @order.payment_method
}
)
render json: { success: true, order_id: @order.id }
end
end
Configuration
Client-Side Configuration
Basic client-side setup:
<!-- Standard setup -->
<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>
<!-- With extensions -->
<script defer
data-domain="example.com"
src="https://plausible.io/js/script.outbound-links.js">
</script>
<!-- Custom proxy -->
<script defer
data-domain="example.com"
data-api="/stats/api/event"
src="/stats/js/script.js">
</script>
Server-Side Configuration
API endpoint configuration:
// Production endpoint
const PLAUSIBLE_API = 'https://plausible.io/api/event';
// Self-hosted endpoint
const PLAUSIBLE_API = 'https://analytics.yourdomain.com/api/event';
// Configuration object
const plausibleConfig = {
apiEndpoint: process.env.PLAUSIBLE_API || 'https://plausible.io/api/event',
domain: process.env.PLAUSIBLE_DOMAIN || 'example.com',
timeout: 5000, // 5 second timeout
retries: 2
};
Environment Variables
# .env file
PLAUSIBLE_API_ENDPOINT=https://plausible.io/api/event
PLAUSIBLE_DOMAIN=example.com
PLAUSIBLE_ENABLED=true
# For self-hosted
PLAUSIBLE_API_ENDPOINT=https://analytics.yourdomain.com/api/event
Coordination & Deduplication
When using both client-side and server-side tracking, careful coordination is required to avoid duplicate events.
Deduplication Strategies
Strategy 1: Separate Event Names
// Client-side: User initiates checkout
plausible('Checkout Started');
// Server-side: Payment confirmed
await trackServerSideEvent('Payment Confirmed', {...});
Strategy 2: Client-Side for Attempts, Server-Side for Success
// Client-side: Track button click
document.querySelector('#purchase-btn').addEventListener('click', () => {
plausible('Purchase Attempted');
});
// Server-side: Track actual completion
app.post('/api/purchase', async (req, res) => {
const order = await processPayment(req.body);
if (order.success) {
await trackServerSideEvent('Purchase', {...});
}
res.json(order);
});
Strategy 3: Conditional Client-Side Tracking
// Only track client-side if server-side tracking will fail
async function checkout() {
try {
const response = await fetch('/api/purchase', {
method: 'POST',
body: JSON.stringify(orderData)
});
// Server will handle tracking
return await response.json();
} catch (error) {
// Fallback: track client-side if server request fails
plausible('Purchase', {
props: { fallback: 'true', error: error.message }
});
throw error;
}
}
Strategy 4: Unique Identifiers
// Generate unique event ID on client
const eventId = `evt-${Date.now()}-${Math.random()}`;
// Client-side: Track with ID
plausible('Purchase', {
props: {
event_id: eventId,
source: 'client'
}
});
// Server-side: Track with same ID (for deduplication in analysis)
await trackServerSideEvent('Purchase', {
props: {
event_id: eventId, // Same ID
source: 'server'
}
});
// Later: Filter by source in analysis or deduplicate by event_id
Event Name Consistency
Keep event names identical between client and server to maintain consistent reporting:
// Define event names in shared constants
const EVENTS = {
SIGNUP: 'Signup',
PURCHASE: 'Purchase',
SUBSCRIPTION: 'Subscription',
DOWNLOAD: 'Download'
};
// Client-side
plausible(EVENTS.SIGNUP, {props: {method: 'email'}});
// Server-side
await trackServerSideEvent(EVENTS.SIGNUP, {props: {method: 'email'}});
Hybrid Implementation Pattern
// Shared event tracker that decides client vs server
async function trackEvent(eventName, options = {}, forceServerSide = false) {
const isServerEnvironment = typeof window === 'undefined';
if (isServerEnvironment || forceServerSide) {
// Server-side tracking
return await trackServerSideEvent(eventName, options);
} else {
// Client-side tracking
return plausible(eventName, options);
}
}
// Usage works in both environments
// In browser:
await trackEvent('Button Click', {props: {location: 'hero'}});
// On server:
await trackEvent('Order Processed', {
revenue: {currency: 'USD', amount: 99.99}
});
// Force server-side from client (critical event):
await trackEvent('Purchase', {
revenue: {currency: 'USD', amount: 99.99}
}, true); // forceServerSide = true
Validation Steps
Client-Side Validation
- Browser Console Check:
// Verify plausible function exists
console.log(typeof window.plausible); // Should be 'function'
// Test event
plausible('Test Event', {props: {test: 'true'}});
// Check network tab for POST to /api/event
- Real-Time Dashboard:
- Open Plausible dashboard
- Trigger client-side event
- Verify event appears within 1-2 seconds
- Ad Blocker Test:
- Enable ad blocker (uBlock Origin, AdBlock Plus)
- Trigger event
- Verify if event is blocked
- If blocked, implement proxy workaround
Server-Side Validation
- Server Logs:
// Add logging to server-side tracking
async function trackServerSideEvent(eventName, options) {
console.log(`Tracking server-side event: ${eventName}`, options);
try {
const response = await axios.post(PLAUSIBLE_API, payload, {headers});
console.log(`Event tracked successfully: ${response.status}`);
} catch (error) {
console.error(`Failed to track event: ${error.message}`);
}
}
- API Response Validation:
// Check Plausible API response
const response = await trackServerSideEvent('Test', {...});
// Successful response: HTTP 202 Accepted
// Failed response: HTTP 400 Bad Request (check payload format)
- IP and User Agent Verification:
// Verify IP and User Agent are being forwarded
console.log('IP:', req.ip);
console.log('User Agent:', req.headers['user-agent']);
// Check Plausible dashboard for correct location data
End-to-End Testing
// Test complete flow
describe('Analytics Tracking', () => {
it('should track purchase both client and server side', async () => {
// Simulate purchase flow
const orderId = await initiatePurchase();
// Verify client-side event fired
expect(mockPlausible).toHaveBeenCalledWith('Checkout Started');
// Complete purchase (triggers server-side event)
const order = await completePurchase(orderId);
// Verify server-side event was sent
expect(plausibleApiMock).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Purchase',
revenue: expect.any(Object)
})
);
});
});
Troubleshooting
| Issue | Possible Cause | Solution |
|---|---|---|
| Server-side events not appearing | Incorrect API endpoint | Verify endpoint is https://plausible.io/api/event |
| Location data missing from server events | IP not forwarded | Include X-Forwarded-For header with client IP |
| Device/browser data missing | User agent not sent | Include User-Agent header from client request |
| Duplicate events appearing | Both client and server tracking same event | Use different event names or conditional tracking |
| Server-side API returns 400 | Invalid payload format | Check JSON structure matches Plausible API spec |
| Events tracked but no revenue data | Revenue format incorrect | Ensure revenue object has currency and amount |
| High latency from server tracking | Synchronous tracking blocks request | Use async/background jobs for event tracking |
| Server-side events show wrong country | IP from proxy/load balancer | Forward original client IP, not proxy IP |
| Self-hosted endpoint not working | CORS or network issue | Check firewall rules and CORS configuration |
| Client-side blocked by ad blocker | Default Plausible domain blocked | Implement custom proxy for first-party tracking |
| Server tracking fails silently | No error handling | Add try-catch and logging to track failures |
| Events sent but not counted | Event name doesn't match goal | Verify goal is configured in dashboard |
| Timestamp issues | Server timezone misconfiguration | Plausible uses request time; verify server time is correct |
| Rate limiting errors | Too many requests | Implement request batching or queuing |
| Authentication errors on self-hosted | Missing API key | Include API key header for self-hosted instances |
Best Practices
1. Use Hybrid Approach
Combine client-side and server-side for optimal coverage:
// Client-side: Most events, user interactions
plausible('Product View', {...});
plausible('Add to Cart', {...});
// Server-side: Critical conversions only
await trackServerSideEvent('Purchase', {...});
await trackServerSideEvent('Subscription Created', {...});
2. Async Server-Side Tracking
Don't block user requests waiting for analytics:
// Bad: Blocking
app.post('/purchase', async (req, res) => {
const order = await processOrder(req.body);
await trackServerSideEvent('Purchase', {...}); // Blocks response!
res.json(order);
});
// Good: Non-blocking
app.post('/purchase', async (req, res) => {
const order = await processOrder(req.body);
// Track in background
trackServerSideEvent('Purchase', {...}).catch(err => {
console.error('Analytics error:', err);
});
res.json(order); // Don't wait for analytics
});
// Better: Queue-based
app.post('/purchase', async (req, res) => {
const order = await processOrder(req.body);
// Add to queue
analyticsQueue.add({
event: 'Purchase',
options: {...}
});
res.json(order);
});
3. Privacy-Compliant IP Forwarding
Be careful with IP addresses:
// Hash or anonymize if required
function getAnonymizedIp(fullIp) {
// Remove last octet for IPv4
const parts = fullIp.split('.');
parts[3] = '0';
return parts.join('.');
}
await trackServerSideEvent('Event', {
ip: getAnonymizedIp(req.ip)
});
4. Consistent Event Naming
Use constants to ensure consistency:
// constants/analytics.js
export const EVENTS = {
SIGNUP: 'Signup',
PURCHASE: 'Purchase',
DOWNLOAD: 'Download'
};
// Use everywhere
import { EVENTS } from './constants/analytics';
// Client
plausible(EVENTS.SIGNUP);
// Server
await trackServerSideEvent(EVENTS.SIGNUP);
5. Error Handling
Always handle tracking errors gracefully:
async function trackServerSideEvent(eventName, options) {
try {
await axios.post(PLAUSIBLE_API, payload, {
headers,
timeout: 5000
});
} catch (error) {
// Log but don't throw
console.error('Analytics tracking failed:', {
event: eventName,
error: error.message
});
// Optional: Send to error monitoring
Sentry.captureException(error);
// Don't let analytics failures break app functionality
}
}
6. Testing Strategy
Test both approaches:
// Mock client-side tracking
window.plausible = jest.fn();
// Mock server-side API
const mockAxios = jest.spyOn(axios, 'post');
// Test
test('tracks purchase correctly', async () => {
await purchase();
expect(window.plausible).toHaveBeenCalledWith('Checkout Started');
expect(mockAxios).toHaveBeenCalledWith(
'https://plausible.io/api/event',
expect.objectContaining({ name: 'Purchase' })
);
});
7. Performance Monitoring
Track analytics performance:
async function trackServerSideEvent(eventName, options) {
const startTime = Date.now();
try {
await axios.post(PLAUSIBLE_API, payload, {headers});
const duration = Date.now() - startTime;
if (duration > 1000) {
console.warn(`Slow analytics tracking: ${duration}ms for ${eventName}`);
}
} catch (error) {
console.error('Analytics error:', error);
}
}
8. Feature Flags
Use feature flags to control tracking:
async function trackEvent(eventName, options) {
if (!config.analyticsEnabled) {
return; // Skip in development
}
if (config.serverSideTracking) {
await trackServerSideEvent(eventName, options);
} else {
plausible(eventName, options);
}
}
9. Documentation
Document which events use which method:
/**
* Analytics Event Catalog
*
* Client-Side Events:
* - Product View (user interaction)
* - Add to Cart (user interaction)
* - Search (user interaction)
*
* Server-Side Events:
* - Purchase (critical conversion)
* - Signup (critical conversion)
* - Subscription Created (backend process)
*
* Hybrid Events (tracked both places):
* - None (avoided for deduplication)
*/
10. Graceful Degradation
Ensure analytics failures don't break functionality:
function trackSafely(eventName, options = {}) {
try {
if (typeof plausible === 'function') {
plausible(eventName, options);
}
} catch (error) {
// Silently fail
console.warn('Analytics error:', error.message);
}
}
// Use wrapper instead of direct calls
trackSafely('Button Click', {props: {location: 'hero'}});