Add real-time page analytics directly in your Sanity Studio CMS using PostHog data.
A Sanity Studio plugin that displays page-specific PostHog analytics with:
7-day pageview trends
Total view counts
Interactive charts using Sanity UI components
Sanity + Next.js project using the official template as an example here
PostHog account with Personal API Key
Create studio/src/environment.ts
:
// src/environment.ts
export const posthogUrl = process.env.SANITY_STUDIO_POSTHOG_URL
export const posthogProjectId = process.env.SANITY_STUDIO_POSTHOG_PROJECT_ID
export const posthogPersonalApiKey = process.env.SANITY_STUDIO_POSTHOG_PERSONAL_API_KEY
Create studio/src/plugins/analytics/AnalyticsView.tsx
:
import React, { useState, useEffect } from 'react'
import {
Box, Card, Container, Flex, Grid, Heading, Spinner, Stack, Text, Code
} from '@sanity/ui'
interface AnalyticsPluginConfig {
posthogUrl: string
posthogProjectId?: string
posthogPersonalApiKey?: string
}
interface AnalyticsViewProps {
document: {
displayed: {
_type: string
_id: string
name?: string
slug?: { current: string }
}
}
config: AnalyticsPluginConfig
}
interface AnalyticsData {
date: string
pageviews: number
}
export const AnalyticsView: React.FC<AnalyticsViewProps> = ({ document, config }) => {
const [analytics, setAnalytics] = useState<AnalyticsData[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [totalViews, setTotalViews] = useState(0)
const pageSlug = document?.displayed?.slug?.current
const pageName = document?.displayed?.name || 'Untitled Page'
useEffect(() => {
const fetchAnalytics = async () => {
if (!pageSlug || !config.posthogProjectId || !config.posthogPersonalApiKey) {
setError('Missing configuration or page slug')
setLoading(false)
return
}
try {
setLoading(true)
const url = `${config.posthogUrl}/api/projects/${config.posthogProjectId}/query/`
const query = `
SELECT
toDate(timestamp) AS date,
count() AS pageviews
FROM events
WHERE
event = '$pageview'
AND properties.$pathname = '/${pageSlug}'
GROUP BY date
ORDER BY date DESC
LIMIT 7
`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.posthogPersonalApiKey}`
},
body: JSON.stringify({
query: { kind: 'HogQLQuery', query }
}),
})
if (!response.ok) {
throw new Error(`PostHog API error: ${await response.text()}`)
}
const data = await response.json()
const results = data.results || []
const transformedResults = results.map((row: any[]) => ({
date: row[0],
pageviews: row[1] || 0
}))
setAnalytics(transformedResults)
setTotalViews(transformedResults.reduce((sum, item) => sum + item.pageviews, 0))
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch analytics')
} finally {
setLoading(false)
}
}
fetchAnalytics()
}, [pageSlug, config])
if (loading) {
return (
<Container width={4}>
<Card padding={4} radius={2}>
<Flex align="center" justify="center" direction="column" gap={3}>
<Spinner muted />
<Text muted>Loading analytics...</Text>
</Flex>
</Card>
</Container>
)
}
if (error) {
return (
<Container width={4}>
<Card padding={4} radius={2} tone="critical">
<Stack space={4}>
<Heading size={2}>Error loading analytics</Heading>
<Text>{error}</Text>
</Stack>
</Card>
</Container>
)
}
return (
<Container width={1} paddingY={4}>
<Stack space={4}>
<Stack space={2}>
<Heading size={3}>📊 Page Analytics</Heading>
<Text muted size={2}>{pageName}</Text>
</Stack>
<Grid columns={[1, 2]} gap={4}>
<Card padding={4} radius={2} border>
<Flex direction="column" align="center" justify="center" gap={3}>
<Text size={1} weight="semibold">Total Views (7 days)</Text>
<Text size={4} weight="bold" style={{ color: 'var(--card-accent-fg-color)' }}>
{totalViews.toLocaleString()}
</Text>
</Flex>
</Card>
<Card padding={4} radius={2} border>
<Flex direction="column" align="center" justify="center" gap={3}>
<Text size={1} weight="semibold">Page Slug</Text>
<Code size={2}>/{pageSlug}</Code>
</Flex>
</Card>
</Grid>
<Card padding={4} radius={2} border>
<Stack space={4}>
<Heading size={2}>Daily Pageviews (Last 7 Days)</Heading>
{analytics.length === 0 ? (
<Box paddingY={6}>
<Text align="center" muted>No analytics data found for this page</Text>
</Box>
) : (
<Stack space={3}>
{analytics.map((item, index) => {
const maxViews = Math.max(...analytics.map(a => a.pageviews))
const barWidth = maxViews > 0 ? (item.pageviews / maxViews) * 100 : 0
return (
<Flex key={index} align="center" gap={3} paddingY={2}>
<Box style={{ width: '100px' }}>
<Text size={1} muted>
{new Date(item.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
})}
</Text>
</Box>
<Box
flex={1}
style={{
height: '24px',
backgroundColor: 'var(--card-border-color)',
borderRadius: '4px',
position: 'relative'
}}
>
<div
style={{
height: '100%',
width: `${barWidth}%`,
backgroundColor: 'var(--card-accent-fg-color)',
borderRadius: '4px',
transition: 'width 0.3s ease'
}}
/>
</Box>
<Box style={{ width: '50px' }}>
<Text size={1} weight="semibold" align="right">
{item.pageviews}
</Text>
</Box>
</Flex>
)
})}
</Stack>
)}
</Stack>
</Card>
</Stack>
</Container>
)
}
Create studio/src/plugins/analytics/index.ts:
import { definePlugin } from 'sanity'
import { posthogUrl, posthogProjectId, posthogPersonalApiKey } from '../../environment'
export interface AnalyticsPluginConfig {
posthogUrl?: string
posthogProjectId?: string
posthogPersonalApiKey?: string
}
export const analyticsPlugin = definePlugin<AnalyticsPluginConfig | void>((config) => {
const pluginConfig = {
posthogUrl: config?.posthogUrl || posthogUrl || 'https://us.posthog.com',
posthogProjectId: config?.posthogProjectId || posthogProjectId,
posthogPersonalApiKey: config?.posthogPersonalApiKey || posthogPersonalApiKey,
}
return {
name: 'analytics-plugin',
title: 'Analytics Plugin',
__internal: {
analyticsConfig: pluginConfig,
},
}
})
export { AnalyticsView } from './AnalyticsView'
Modify studio/src/structure/index.ts:
import {CogIcon} from '@sanity/icons'
import type {StructureBuilder, StructureResolver} from 'sanity/structure'
import pluralize from 'pluralize-esm'
import {AnalyticsView} from '../plugins/analytics'
import {posthogUrl, posthogProjectId, posthogPersonalApiKey} from '../environment'
const DISABLED_TYPES = ['settings', 'assist.instruction.context']
export const structure: StructureResolver = (S: StructureBuilder) =>
S.list()
.title('Website Content')
.items([
...S.documentTypeListItems()
.filter((listItem: any) => !DISABLED_TYPES.includes(listItem.getId()))
.map((listItem) => {
const id = listItem.getId()
// Add analytics views for page documents
if (id === 'page') {
const analyticsConfig = {
posthogUrl: posthogUrl || 'https://us.posthog.com',
posthogProjectId,
posthogPersonalApiKey,
}
return listItem
.title(pluralize(listItem.getTitle() as string))
.child(
S.documentTypeList('page')
.title('Pages')
.child((documentId) =>
S.document()
.documentId(documentId)
.schemaType('page')
.views([
S.view.form(),
S.view.component((props) => AnalyticsView({...props, config: analyticsConfig})).title('Analytics'),
])
)
)
}
return listItem.title(pluralize(listItem.getTitle() as string))
}),
S.listItem()
.title('Site Settings')
.child(S.document().schemaType('settings').documentId('siteSettings'))
.icon(CogIcon),
])
Update studio/sanity.config.ts:
import {analyticsPlugin} from './src/plugins/analytics'
import {posthogUrl, posthogProjectId, posthogPersonalApiKey} from './src/environment'
export default defineConfig({
// ... existing config
plugins: [
// ... existing plugins
analyticsPlugin({
posthogUrl,
posthogProjectId,
posthogPersonalApiKey,
}),
],
})
Update studio/.env.local:
SANITY_STUDIO_POSTHOG_URL=https://us.posthog.com
SANITY_STUDIO_POSTHOG_PROJECT_ID=your-project-id
SANITY_STUDIO_POSTHOG_PERSONAL_API_KEY=phx_your-api-key
After all of this, you should be able to view page specific analytics like this:
Since we are using Sanity UI components, everything can blend in to the UI of sanity's studio, pretty neat right? This is just a starting point, with the posthog queries we can customize the analytics as per your content writers needs and hopefully eliminate a tab in their chrome window.
Credits:
- Blog written with cursor + calude 4 sonnet
- Cover image made with coverview
- This was an internal project at pixbox labs, hit us up if you would like a CMS customized like this 🙌
Join Shreyas on Peerlist!
Join amazing folks like Shreyas and thousands of other people in tech.
Create ProfileJoin with Shreyas’s personal invite link.
0
12
0