Shreyas R

Jun 13, 2025 • 5 min read

Building a PostHog Analytics Plugin for Sanity Studio

Add real-time page analytics directly in your Sanity Studio CMS using PostHog data.

Building a PostHog Analytics Plugin for Sanity Studio

What We're Building

A Sanity Studio plugin that displays page-specific PostHog analytics with:

  • 7-day pageview trends

  • Total view counts

  • Interactive charts using Sanity UI components


Prerequisites

  • Sanity + Next.js project using the official template as an example here

  • PostHog account with Personal API Key


Implementation

1. Environment Configuration

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

2. Analytics Component

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>
  )
}

3. Plugin Definition

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'

4. Update Structure

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),
    ])

5. Register Plugin

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,
    }),
  ],
})

6. Environment Variables

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 Profile

Join with Shreyas’s personal invite link.

0

12

0