Chapter 47 · Instrument Integration
Subchapter 47.24
references/EXAMPLE-nuxt-4.mdMarkdown29 KBView on GitHub
Repository: https://github.com/PostHog/context-mill Path: basics/nuxt-4
This is a Nuxt 4 (opens in a new tab) example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.
Nuxt 4 supports the @posthog/nuxt package, which provides automatic PostHog integration with built-in error tracking, source map uploads, and simplified configuration. This is the recommended approach for Nuxt 4+.
For Nuxt 3.0 - 3.6, you must use the posthog-js and posthog-node packages directly instead. See the Nuxt 3.6 example (opens in a new tab) for that approach.
npm install
# or
pnpm installCreate a .env file in the root directory:
NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_project_token
NUXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
# Optional: For source map uploads
PROJECT_ID=your_project_id
PERSONAL_API_KEY=your_personal_api_keyGet your PostHog project token from your PostHog project settings (opens in a new tab).
For source map uploads, get your project ID from PostHog environment variables (opens in a new tab) and your personal API key from PostHog user API keys (opens in a new tab) (requires organization:read and error_tracking:write scopes).
npm run dev
# or
pnpm devOpen http://localhost:3000 (opens in a new tab) with your browser to see the app.
├── app/
│ ├── components/
│ │ └── AppHeader.vue # Navigation header with auth state
│ ├── composables/
│ │ └── useAuth.ts # Authentication composable
│ ├── middleware/
│ │ └── auth.ts # Authentication middleware
│ ├── pages/
│ │ ├── index.vue # Home/Login page
│ │ ├── burrito.vue # Demo feature page with event tracking
│ │ └── profile.vue # User profile with error tracking demo
│ ├── utils/
│ │ └── formValidation.ts # Form validation utilities
│ └── app.vue # Root component
├── assets/
│ └── css/
│ └── main.css # Global styles
├── server/
│ ├── api/
│ │ ├── auth/
│ │ │ └── login.post.ts # Login API with server-side tracking
│ │ └── burrito/
│ │ └── consider.post.ts # Burrito consideration API with server-side tracking
│ └── utils/
│ ├── posthog.ts # Server-side PostHog utility
│ └── users.ts # In-memory user storage utilities
├── nuxt.config.ts # Nuxt configuration with PostHog module
└── package.jsonNuxt 4 uses the @posthog/nuxt module for automatic PostHog integration:
export default defineNuxtConfig({
modules: ['@posthog/nuxt'],
runtimeConfig: {
public: {
posthog: {
publicKey: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN || '',
host: process.env.NUXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
},
},
},
posthogConfig: {
publicKey: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN || '',
host: process.env.NUXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
clientConfig: {
capture_exceptions: true, // Enables automatic exception capture on the client side (Vue)
__add_tracing_headers: ['localhost', 'yourdomain.com'], // Add your domain here
},
serverConfig: {
enableExceptionAutocapture: true, // Enables automatic exception capture on the server side (Nitro)
},
sourcemaps: {
enabled: true,
envId: process.env.PROJECT_ID || '',
personalApiKey: process.env.PERSONAL_API_KEY || '',
project: 'my-application',
version: '1.0.0',
},
},
})Key Points:
@posthog/nuxt module handles PostHog initialization automaticallycapture_exceptions: trueenableExceptionAutocapture: true__add_tracing_headers option automatically adds X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers to requestsImportant: do not identify users on the server-side.
The user is identified when the user logs in on the client-side.
const posthog = usePostHog()
const handleSubmit = async () => {
const success = await auth.login(formData.username, formData.password)
if (success) {
// Identifying the user once on login/sign up is enough.
posthog?.identify(formData.username)
// Capture login event
posthog?.capture('user_logged_in')
}
}The session and distinct ID are automatically passed to the backend via the X-POSTHOG-SESSION-ID and X-POSTHOG-DISTINCT-ID headers because we set the __add_tracing_headers option in the PostHog configuration.
Important: do not identify users on the server-side.
Server-side API routes use the useServerPostHog() utility to get a PostHog Node client and extract session and user context from request headers:
import { useServerPostHog } from '../../utils/posthog'
import { getOrCreateUser, users } from '../../utils/users'
export default defineEventHandler(async (event) => {
const body = await readBody<{ username: string; password: string }>(event)
const { username, password } = body || {}
if (!username || !password) {
throw createError({
statusCode: 400,
message: 'Username and password required',
})
}
const user = getOrCreateUser(username)
const isNewUser = !users.has(username)
const sessionId = getHeader(event, 'x-posthog-session-id')
const distinctId = getHeader(event, 'x-posthog-distinct-id')
// Capture server-side login event
const posthog = useServerPostHog()
posthog.capture({
distinctId: distinctId,
event: 'server_login',
properties: {
$session_id: sessionId,
username: username,
isNewUser: isNewUser,
source: 'api',
},
})
return {
success: true,
user,
}
})Key Points:
useServerPostHog() utility to get a shared PostHog Node client instancesessionId and distinctId from request headers using getHeader() (auto-imported from h3)defineEventHandler, readBody, createError, getHeader are auto-imported in server routesThe burrito consideration page demonstrates both client-side and server-side event tracking:
const posthog = usePostHog()
const handleConsideration = async () => {
if (!user.value) return
try {
// Call server-side API route
const response = await $fetch('/api/burrito/consider', {
method: 'POST',
body: { username: user.value.username },
})
if (response.success && response.user) {
auth.setUser(response.user)
hasConsidered.value = true
// Client-side tracking (in addition to server-side tracking)
posthog?.capture('burrito_considered', {
total_considerations: response.user.burritoConsiderations,
username: response.user.username,
})
setTimeout(() => {
hasConsidered.value = false
}, 2000)
}
} catch (err) {
console.error('Error considering burrito:', err)
}
}The server-side route (server/api/burrito/consider.post.ts) also captures the event, demonstrating dual tracking.
Errors are captured automatically in multiple ways:
Automatic client-side capture - The @posthog/nuxt module automatically captures Vue errors when capture_exceptions: true is set in posthogConfig.clientConfig.
Automatic server-side capture - The module automatically captures Nitro errors when enableExceptionAutocapture: true is set in posthogConfig.serverConfig.
Manual error capture in components (app/pages/profile.vue):
const posthog = usePostHog()
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking')
} catch (err) {
posthog?.captureException(err)
}
}Server-side events use the shared PostHog Node client. Note that h3 functions are auto-imported in Nuxt server routes:
import { useServerPostHog } from '../../utils/posthog'
import { getOrCreateUser, users } from '../../utils/users'
export default defineEventHandler(async (event) => {
const body = await readBody<{ username: string; password: string }>(event)
const { username, password } = body || {}
// ... validation logic ...
// Extract headers using getHeader (auto-imported from h3)
const sessionId = getHeader(event, 'x-posthog-session-id')
const distinctId = getHeader(event, 'x-posthog-distinct-id')
// Capture server-side event
const posthog = useServerPostHog()
posthog.capture({
distinctId: distinctId,
event: 'server_login',
properties: {
$session_id: sessionId,
username: username,
isNewUser: isNewUser,
source: 'api',
},
})
return { success: true, user }
})Key Points:
useServerPostHog() utilitygetHeader() is auto-imported from h3 in Nuxt server routes (no need to import from ‘h3’)defineEventHandler, readBody, createError are also auto-importeddistinctId and sessionId are extracted from request headers and used to maintain context between client and serverPostHog is accessed via the usePostHog() composable provided by @posthog/nuxt:
const posthog = usePostHog()
posthog?.capture('event_name', { property: 'value' })The composable is automatically typed and available throughout your Nuxt application.
The server utility provides a shared PostHog Node client instance:
import { PostHog } from 'posthog-node'
let client: PostHog | null = null
export function useServerPostHog(): PostHog {
if (!client) {
const config = useRuntimeConfig()
const posthogConfig = config.public.posthog
client = new PostHog(posthogConfig.publicKey, {
host: posthogConfig.host,
})
}
return client
}This ensures a single PostHog client instance is reused across all server requests, improving performance.
@posthog/nuxt module instead of manual plugin setupusePostHog() composable instead of useNuxtApp().$posthogdefineEventHandler, readBody, createError, getHeader, etc.) are auto-imported - no need to import them explicitlyNUXT_PUBLIC_POSTHOG_PROJECT_TOKEN=
NUXT_PUBLIC_POSTHOG_HOST=
PROJECT_ID=
PERSONAL_API_KEY=<template>
<div style="min-height: 100vh; display: flex; flex-direction: column; background: #f5f5f5; width: 100%;">
<AppHeader />
<main style="flex: 1;">
<NuxtPage />
</main>
</div>
</template>
<template>
<header class="header">
<div class="header-container">
<nav>
<NuxtLink to="/">Home</NuxtLink>
<template v-if="user">
<NuxtLink to="/burrito">Burrito Consideration</NuxtLink>
<NuxtLink to="/profile">Profile</NuxtLink>
</template>
</nav>
<div class="user-section">
<span v-if="user">Welcome, {{ user.username }}!</span>
<span v-else>Not logged in</span>
<button v-if="user" @click="handleLogout" class="btn-logout">Logout</button>
</div>
</div>
</header>
</template>
<script setup lang="ts">
const posthog = usePostHog()
const auth = useAuth()
const user = computed(() => auth.user.value)
const handleLogout = async () => {
auth.logout()
posthog?.capture('user_logged_out')
posthog?.reset()
await navigateTo('/')
}
</script>
interface User {
username: string
burritoConsiderations: number
}
const users: Map<string, User> = new Map()
export function useAuth() {
const user = useState<User | null>('auth-user', () => {
if (process.client) {
const storedUsername = localStorage.getItem('currentUser')
if (storedUsername) {
const existingUser = users.get(storedUsername)
if (existingUser) {
return existingUser
}
}
}
return null
})
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await $fetch<{ success: boolean; user: User }>('/api/auth/login', {
method: 'POST',
body: { username, password },
})
if (response.success) {
let localUser = users.get(username)
if (!localUser) {
localUser = response.user
users.set(username, localUser)
}
user.value = localUser
if (process.client) {
localStorage.setItem('currentUser', username)
}
return true
}
return false
} catch (error) {
console.error('Login error:', error)
return false
}
}
const logout = () => {
user.value = null
if (process.client) {
localStorage.removeItem('currentUser')
}
}
const incrementBurritoConsiderations = () => {
if (user.value) {
user.value.burritoConsiderations++
users.set(user.value.username, user.value)
// Trigger reactivity
user.value = { ...user.value }
}
}
const setUser = (newUser: User) => {
user.value = newUser
users.set(newUser.username, newUser)
}
return {
user,
login,
logout,
incrementBurritoConsiderations,
setUser,
}
}
export default defineNuxtRouteMiddleware((to, from) => {
const auth = useAuth()
const user = auth.user.value
// If user is not logged in, redirect to home/login page
if (!user) {
return navigateTo('/')
}
})
<template>
<div class="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style="text-align: center">
<button @click="handleConsideration" class="btn-burrito">
I have considered the burrito potential
</button>
<p v-if="hasConsidered" class="success">
Thank you for your consideration! Count: {{ user?.burritoConsiderations }}
</p>
</div>
<div class="stats">
<h3>Consideration stats</h3>
<p>Total considerations: {{ user?.burritoConsiderations }}</p>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth'
})
const auth = useAuth()
const user = computed(() => auth.user.value)
const posthog = usePostHog()
const hasConsidered = ref(false)
const handleConsideration = async () => {
if (!user.value) return
try {
const response = await $fetch('/api/burrito/consider', {
method: 'POST',
body: { username: user.value.username },
})
if (response.success && response.user) {
auth.setUser(response.user)
hasConsidered.value = true
// Client-side tracking (in addition to server-side tracking)
posthog?.capture('burrito_considered', {
total_considerations: response.user.burritoConsiderations,
username: response.user.username,
})
setTimeout(() => {
hasConsidered.value = false
}, 2000)
}
} catch (err) {
console.error('Error considering burrito:', err)
}
}
</script>
<template>
<div class="container">
<h1 v-if="user">Welcome back, {{ user.username }}!</h1>
<h1 v-else>Welcome to Burrito Consideration App</h1>
<div v-if="user">
<p>You are now logged in. Feel free to explore:</p>
<ul>
<li
<template>
<div class="container">
<h1>User Profile</h1>
<div class="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> {{ user?.username }}</p>
<p><strong>Burrito Considerations:</strong> {{ user?.burritoConsiderations }}</p>
</div>
<div style="margin-top: 2rem">
<button @click="triggerTestError" class="btn-primary" style="background-color: #dc3545">
Trigger Test Error (for PostHog)
</button>
</div>
<div style="margin-top: 2rem">
<h3>Your Burrito Journey</h3>
<p v-if="user?.burritoConsiderations === 0">
You haven't considered any burritos yet. Visit the Burrito Consideration page to start!
</p>
<p v-else-if="user?.burritoConsiderations === 1">
You've considered the burrito potential once. Keep going!
</p>
<p v-else-if="user && user.burritoConsiderations < 5">
You're getting the hang of burrito consideration!
</p>
<p v-else-if="user && user.burritoConsiderations < 10">
You're becoming a burrito consideration expert!
</p>
<p v-else>You are a true burrito consideration master! 🌯</p>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth'
})
const auth = useAuth()
const user = computed(() => auth.user.value)
const posthog = usePostHog()
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking')
} catch (err) {
console.error('Captured error:', err)
posthog?.captureException(err)
}
}
</script>
import { z } from 'zod'
export const loginSchema = z.object({
username: z
.string()
.min(1, 'Username is required')
.min(3, 'Username must be at least 3 characters')
.max(50, 'Username must be less than 50 characters'),
password: z
.string()
.min(1, 'Password is required')
.min(3, 'Password must be at least 3 characters'),
})
export type LoginFormData = z.infer<typeof loginSchema>
export function validateForm<T>(schema: z.ZodSchema<T>, data: unknown): {
success: boolean
data?: T
errors?: Record<string, string>
} {
const result = schema.safeParse(data)
if (result.success) {
return { success: true, data: result.data }
}
const errors: Record<string, string> = {}
result.error.errors.forEach((error) => {
const path = error.path.join('.')
errors[path] = error.message
})
return { success: false, errors }
}
import { fileURLToPath } from 'node:url'
import { resolve, dirname } from 'node:path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
css: [resolve(__dirname, 'assets/css/main.css')],
modules: ['@posthog/nuxt'],
runtimeConfig: {
public: {
posthog: {
publicKey: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN || '',
host: process.env.NUXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
},
},
},
posthogConfig: {
publicKey: process.env.NUXT_PUBLIC_POSTHOG_PROJECT_TOKEN || '', // Find it in project settings https://app.posthog.com/settings/project
host: process.env.NUXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com', // Optional: defaults to https://us.i.posthog.com. Use https://eu.i.posthog.com for EU region
clientConfig: {
capture_exceptions: true, // Enables automatic exception capture on the client side (Vue)
__add_tracing_headers: [ 'localhost', 'yourdomain.com' ], // Add your domain here
},
serverConfig: {
enableExceptionAutocapture: true, // Enables automatic exception capture on the server side (Nitro)
},
sourcemaps: {
enabled: true,
envId: process.env.PROJECT_ID || '', // Your project ID from PostHog settings https://app.posthog.com/settings/environment#variables
personalApiKey: process.env.PERSONAL_API_KEY || '', // Your personal API key from PostHog settings https://app.posthog.com/settings/user-api-keys (requires organization:read and error_tracking:write scopes)
project: 'my-application', // Optional: defaults to git repository name
version: '1.0.0', // Optional: defaults to current git commit
},
},
})
User-Agent: *
Disallow:
import { useServerPostHog } from '../../utils/posthog'
import { getOrCreateUser, users } from '../../utils/users'
export default defineEventHandler(async (event) => {
const body = await readBody<{ username: string; password: string }>(event)
const { username, password } = body || {}
if (!username || !password) {
throw createError({
statusCode: 400,
message: 'Username and password required',
})
}
const user = getOrCreateUser(username)
const isNewUser = !users.has(username)
const sessionId = getHeader(event, 'x-posthog-session-id')
const distinctId = getHeader(event, 'x-posthog-distinct-id')
// Capture server-side login event
const posthog = useServerPostHog()
posthog.capture({
distinctId: distinctId,
event: 'server_login',
properties: {
$session_id: sessionId,
username: username,
isNewUser: isNewUser,
source: 'api',
},
})
return {
success: true,
user,
}
})
import { useServerPostHog } from '../../utils/posthog'
import { users, incrementBurritoConsiderations } from '../../utils/users'
import { defineEventHandler, readBody, createError, getHeader } from 'h3'
export default defineEventHandler(async (event) => {
const body = await readBody<{ username: string }>(event)
const username = body?.username
if (!username) {
throw createError({
statusCode: 400,
message: 'Username required',
})
}
if (!users.has(username)) {
throw createError({
statusCode: 404,
message: 'User not found',
})
}
// Increment burrito considerations (fake, in-memory)
const user = incrementBurritoConsiderations(username)
const sessionId = getHeader(event, 'x-posthog-session-id')
const distinctId = getHeader(event, 'x-posthog-distinct-id')
// Capture server-side burrito consideration event
const posthog = useServerPostHog()
posthog.capture({
distinctId: distinctId,
event: 'burrito_considered',
properties: {
$session_id: sessionId,
username: username,
total_considerations: user.burritoConsiderations,
source: 'api',
},
})
return {
success: true,
user: { ...user },
}
})
import { PostHog } from 'posthog-node'
let client: PostHog | null = null
export function useServerPostHog(): PostHog {
if (!client) {
const config = useRuntimeConfig()
// The @posthog/nuxt module exposes config at runtimeConfig.public.posthog
const posthogConfig = config.public.posthog
client = new PostHog(posthogConfig.publicKey, {
host: posthogConfig.host,
})
}
return client
}
// Shared in-memory storage for users (fake, no database)
export const users = new Map<string, { username: string; burritoConsiderations: number }>()
export function getOrCreateUser(username: string): { username: string; burritoConsiderations: number } {
let user = users.get(username)
if (!user) {
user = { username, burritoConsiderations: 0 }
users.set(username, user)
}
return user
}
export function incrementBurritoConsiderations(username: string): { username: string; burritoConsiderations: number } {
const user = users.get(username)
if (!user) {
throw new Error('User not found')
}
user.burritoConsiderations++
users.set(username, user)
return { ...user }
}
Nearby