diff --git a/.env.example b/.env.example
index 794129b3..d733dcfd 100644
--- a/.env.example
+++ b/.env.example
@@ -53,12 +53,28 @@ CRON_SECRET=generate-a-random-secret
# BOLAGSVERKET_ARELLE_VALIDATOR_URL=
# BOLAGSVERKET_ARELLE_VALIDATOR_TOKEN=
+# ── Optional: product analytics + error tracking (PostHog) ─
+# Hosted only. Self-hosted deployments never load PostHog: isAnalyticsEnabled()
+# (lib/analytics/enabled.ts) short-circuits on NEXT_PUBLIC_SELF_HOSTED=true, and
+# no __NEXT_PUBLIC_POSTHOG_*__ sentinel is baked into the Docker image, so an
+# operator cannot accidentally ship their users' behaviour to our project.
+#
+# The token is the PUBLIC project token (phc_...). It is embedded in the client
+# bundle by design and is not a secret. Leave unset to run with analytics off.
+# Browser traffic goes through the same-origin /rl rewrite in next.config.ts;
+# NEXT_PUBLIC_POSTHOG_HOST is only used by the server-side SDK.
+# NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=
+# NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
+
# ── Optional: error tracking / observability ──────────────
# The app routes every error-level log line, and anything flagged
-# `alert: true`, to a provider-agnostic sink (lib/observability). No vendor is
-# wired up: the sink is a NO-OP until an adapter is registered with
-# registerObservabilitySink(). Setting these variables alone changes nothing,
-# and self-hosted installs can leave them unset forever.
+# `alert: true`, to a provider-agnostic sink (lib/observability). When the
+# PostHog token above is set, lib/init.ts registers the PostHog adapter
+# (lib/analytics/posthog-observability.ts) as that sink; otherwise the sink
+# stays a NO-OP, the PostHog client is never constructed and nothing is ever
+# sent. (The SDK is still bundled in those builds, since the imports are
+# static; it simply never initialises.) The variables below are for a
+# DIFFERENT vendor adapter and still change nothing on their own.
#
# Names are generic placeholders. When a provider is picked, either keep these
# and read them in the adapter, or replace them with the vendor's own names.
diff --git a/.gitignore b/.gitignore
index 863f28b5..c6cee0f3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -95,4 +95,9 @@ scripts/reopen-bokslut.sql
.claude/plans/write-up-a-plan-streamed-fiddle.md
/ingaende-balanser-test.csv
.agents
-.codex
\ No newline at end of file
+.codex
+# PostHog wizard scratch: vendor reference docs downloaded by
+# `npx @posthog/wizard`, useful locally (and for `wizard audit`) but not
+# part of the app. The integration itself lives in lib/analytics/ and
+# instrumentation-client.ts.
+.posthog/
diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx
index 3c2d6170..05b96028 100644
--- a/app/(dashboard)/layout.tsx
+++ b/app/(dashboard)/layout.tsx
@@ -4,6 +4,7 @@ import DashboardNav from '@/components/dashboard/DashboardNav'
import { MainContainer } from '@/components/dashboard/MainContainer'
import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
import { RecaptIdentify } from '@/components/RecaptIdentify'
+import AnalyticsIdentify from '@/components/AnalyticsIdentify'
import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider'
import AgentTrigger from '@/components/agent/AgentTrigger'
import LazyCommandPalette from '@/components/common/LazyCommandPalette'
@@ -13,7 +14,7 @@ import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider } from '@/contexts/CompanyContext'
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
import { getBranding } from '@/lib/branding/service'
-import type { EntityType, CompanyRole, Team } from '@/types'
+import type { AccountingFramework, EntityType, CompanyRole, Team } from '@/types'
import {
getDashboardAuthContext,
getDashboardCompanyId,
@@ -330,6 +331,25 @@ export default async function DashboardLayout({
displayName={settings?.company_name || undefined}
/>
)}
+ {!isSandbox && (
+
+ )}
)
diff --git a/components/AnalyticsIdentify.tsx b/components/AnalyticsIdentify.tsx
new file mode 100644
index 00000000..6578120e
--- /dev/null
+++ b/components/AnalyticsIdentify.tsx
@@ -0,0 +1,85 @@
+'use client'
+
+import { useEffect } from 'react'
+import posthog from 'posthog-js'
+import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
+import {
+ buildPersonProperties,
+ buildGroupProperties,
+ type AnalyticsCompanyInput,
+ type AnalyticsUserInput,
+} from '@/lib/analytics/properties'
+
+/**
+ * Attaches the logged-in user and their active company to PostHog.
+ *
+ * Mounted from app/(dashboard)/layout.tsx, which is the only place that has
+ * both the auth user and the resolved company in one render, and gated there
+ * on `!isSandbox` so demo companies never pollute funnels or get surveyed.
+ *
+ * identify() runs on every dashboard load rather than only at login, per
+ * PostHog's guidance: with `persistence: 'memory'` (see
+ * instrumentation-client.ts) nothing survives a hard reload, so re-identifying
+ * on each load is what keeps person-level analytics correct without storing
+ * anything on the device.
+ *
+ * A useEffect is correct here despite the general rule against it: this
+ * synchronises with an external, non-React system (the PostHog SDK).
+ */
+export default function AnalyticsIdentify({
+ user,
+ company,
+}: {
+ user: AnalyticsUserInput
+ company: AnalyticsCompanyInput
+}) {
+ const { userId, email, fullName, role } = user
+ const {
+ id: companyId,
+ name: companyName,
+ entityType,
+ accountingFramework,
+ paysSalaries,
+ trialEndsAt,
+ capabilities,
+ } = company
+
+ // The dashboard layout rebuilds the props objects (and the capabilities
+ // array) on every render, so depending on them directly would re-fire
+ // identify on every navigation. Depend on primitives, and collapse the
+ // array to a stable string key.
+ const capabilityKey = capabilities ? [...capabilities].sort().join(',') : ''
+
+ useEffect(() => {
+ if (!isAnalyticsEnabled()) return
+
+ posthog.identify(userId, buildPersonProperties({ userId, email, fullName, role }))
+ posthog.group(
+ 'company',
+ companyId,
+ buildGroupProperties({
+ id: companyId,
+ name: companyName,
+ entityType,
+ accountingFramework,
+ paysSalaries,
+ trialEndsAt,
+ capabilities: capabilityKey ? capabilityKey.split(',') : undefined,
+ })
+ )
+ }, [
+ userId,
+ email,
+ fullName,
+ role,
+ companyId,
+ companyName,
+ entityType,
+ accountingFramework,
+ paysSalaries,
+ trialEndsAt,
+ capabilityKey,
+ ])
+
+ return null
+}
diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx
index 44cd19eb..5d71ca96 100644
--- a/components/dashboard/DashboardNav.tsx
+++ b/components/dashboard/DashboardNav.tsx
@@ -50,6 +50,7 @@ import { getBranding } from '@/lib/branding/service'
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { clearRecaptIdentity } from '@/lib/recapt'
+import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
import { SupportLink } from '@/components/ui/support-link'
import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
import UserMenu from '@/components/dashboard/UserMenu'
@@ -357,6 +358,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const handleLogout = async () => {
clearRecaptIdentity()
+ resetAnalyticsIdentity()
await supabase.auth.signOut()
router.push(isSandbox ? '/sandbox' : '/login')
}
diff --git a/components/settings/sections/AccountSettingsContent.tsx b/components/settings/sections/AccountSettingsContent.tsx
index 821e5958..fdb7c974 100644
--- a/components/settings/sections/AccountSettingsContent.tsx
+++ b/components/settings/sections/AccountSettingsContent.tsx
@@ -23,6 +23,7 @@ import {
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useSettings } from '@/components/settings/useSettings'
import { clearRecaptIdentity } from '@/lib/recapt'
+import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
import { useToast } from '@/components/ui/use-toast'
import { SUPPORTED_LOCALES, type Locale } from '@/i18n/config'
@@ -92,6 +93,7 @@ export function AccountSettingsContent() {
async function handleLogout() {
clearRecaptIdentity()
+ resetAnalyticsIdentity()
await supabase.auth.signOut()
router.push('/login')
}
diff --git a/instrumentation-client.ts b/instrumentation-client.ts
new file mode 100644
index 00000000..fa5e56d6
--- /dev/null
+++ b/instrumentation-client.ts
@@ -0,0 +1,74 @@
+import posthog from 'posthog-js'
+import { isAnalyticsEnabled, warnIfAnalyticsMisconfigured } from '@/lib/analytics/enabled'
+
+/**
+ * Hostnames that get the X-POSTHOG-DISTINCT-ID / X-POSTHOG-SESSION-ID headers,
+ * which is what lets a server error captured in instrumentation.ts link back
+ * to this user's session replay.
+ *
+ * Deliberately our own origin only. Listing a Supabase or third-party host
+ * here would leak PostHog identifiers to them. PostHog matches on hostname
+ * alone, so no protocol and no port ('localhost', never 'localhost:3000').
+ */
+function tracingHosts(): string[] {
+ const hosts = ['localhost', '127.0.0.1']
+ const appUrl = process.env.NEXT_PUBLIC_APP_URL
+ if (appUrl) {
+ try {
+ hosts.push(new URL(appUrl).hostname)
+ } catch {
+ // Malformed NEXT_PUBLIC_APP_URL: skip rather than break init.
+ }
+ }
+ return hosts
+}
+
+/**
+ * Client-side PostHog initialisation.
+ *
+ * This file is the ONLY place posthog.init() is called. Next.js 15.3+ runs
+ * `instrumentation-client` before hydration, which is what PostHog's own
+ * Next.js guidance requires; deliberately NOT combined with a
+ * wrapper, which their example calls out as a mistake.
+ *
+ * Three choices here are deliberate and worth not "fixing":
+ *
+ * 1. `api_host: '/rl'` routes every request through the same-origin rewrite
+ * in next.config.ts. That keeps PostHog first-party, so the strict CSP
+ * needs no third-party hosts at all (`connect-src 'self'` already covers
+ * it) and ad blockers have nothing to match on. The path must stay in the
+ * proxy.ts matcher exclusion or middleware bounces it to /login.
+ *
+ * 2. `persistence: 'memory'` stores nothing on the device. That is what lets
+ * us run analytics without a cookie-consent banner. The cost is that an
+ * anonymous visitor's identity does not survive a hard reload; everything
+ * post-login is unaffected because AnalyticsIdentify re-identifies on
+ * every dashboard load. Note that surveys still write their own
+ * `seenSurvey_*` flags straight to localStorage, bypassing this setting:
+ * that is functional UI state ("don't ask again"), not tracking.
+ *
+ * 3. `maskTextSelector: '*'` (PostHog's documented way to mask ALL text) on
+ * top of the default `maskAllInputs`. This is an accounting app: org
+ * numbers (which for an enskild firma ARE the owner's personnummer),
+ * customer names, balances and invoice amounts are rendered as ordinary
+ * text, and PostHog masks inputs but NOT text by default. Replays are for
+ * seeing WHERE a user gets stuck, never WHAT their books say.
+ */
+if (warnIfAnalyticsMisconfigured() && isAnalyticsEnabled()) {
+ posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
+ api_host: '/rl',
+ ui_host: 'https://eu.posthog.com',
+ defaults: '2026-05-30',
+ // Only create person profiles for users we actually identify: logged-out
+ // visitors stay anonymous and cheap.
+ person_profiles: 'identified_only',
+ persistence: 'memory',
+ capture_exceptions: true,
+ tracing_headers: tracingHosts(),
+ session_recording: {
+ maskAllInputs: true,
+ maskTextSelector: '*',
+ },
+ debug: process.env.NODE_ENV === 'development',
+ })
+}
diff --git a/instrumentation.ts b/instrumentation.ts
index 82148e62..c6cc6494 100644
--- a/instrumentation.ts
+++ b/instrumentation.ts
@@ -1,4 +1,47 @@
+import type { Instrumentation } from 'next'
+
export async function register() {
// Instrumentation hook: currently a no-op.
// Add runtime-specific setup here if needed.
}
+
+/**
+ * Server-side error capture for PostHog Error Tracking.
+ *
+ * Next.js calls this for every uncaught error in a route handler, server
+ * component or server action. Client-side exceptions are handled separately
+ * by `capture_exceptions` in instrumentation-client.ts.
+ *
+ * posthog-node is imported lazily inside the handler rather than at module
+ * scope: `register()` runs in every runtime Next.js boots, and pulling a
+ * Node-only SDK into that graph unconditionally is how instrumentation files
+ * break edge/build-time compilation. A dynamic import keeps the cost on the
+ * error path only.
+ *
+ * The distinct id comes from the `X-POSTHOG-DISTINCT-ID` header that
+ * posthog-js attaches to same-origin fetches (see `tracing_headers` in
+ * instrumentation-client.ts), which is what links a server error back to the
+ * user's session replay. Absent that header the error is still captured, just
+ * unattributed.
+ */
+export const onRequestError: Instrumentation.onRequestError = async (err, request) => {
+ try {
+ const { getPostHogServer, flushAnalytics } = await import('@/lib/analytics/posthog-server')
+ const posthog = getPostHogServer()
+ if (!posthog) return
+
+ const headers = request.headers as Record | undefined
+ const raw = headers?.['x-posthog-distinct-id']
+ const distinctId = Array.isArray(raw) ? raw[0] : raw
+
+ posthog.captureException(err instanceof Error ? err : new Error(String(err)), distinctId, {
+ path: request.path,
+ method: request.method,
+ })
+ // Vercel tears the function down per invocation: without the awaited
+ // flush the enqueued event is silently dropped.
+ await flushAnalytics()
+ } catch {
+ // Telemetry must never turn a handled error into a second failure.
+ }
+}
diff --git a/lib/analytics/__tests__/enabled.test.ts b/lib/analytics/__tests__/enabled.test.ts
new file mode 100644
index 00000000..38149372
--- /dev/null
+++ b/lib/analytics/__tests__/enabled.test.ts
@@ -0,0 +1,66 @@
+import { describe, it, expect, vi, afterEach } from 'vitest'
+import { isAnalyticsEnabled, warnIfAnalyticsMisconfigured, POSTHOG_TOKEN_VAR } from '../enabled'
+
+describe('analytics gate', () => {
+ afterEach(() => {
+ vi.unstubAllEnvs()
+ vi.restoreAllMocks()
+ })
+
+ describe('isAnalyticsEnabled', () => {
+ it('returns false when NEXT_PUBLIC_SELF_HOSTED is true, even with a token', () => {
+ vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
+ vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', 'phc_test')
+ expect(isAnalyticsEnabled()).toBe(false)
+ })
+
+ it('returns true when a token is set and not self-hosted', () => {
+ vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
+ vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', 'phc_test')
+ expect(isAnalyticsEnabled()).toBe(true)
+ })
+
+ it('returns false when the token is unset', () => {
+ vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
+ vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', '')
+ expect(isAnalyticsEnabled()).toBe(false)
+ })
+ })
+
+ describe('warnIfAnalyticsMisconfigured', () => {
+ it('returns true and stays quiet when configured', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
+ vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', 'phc_test')
+ expect(warnIfAnalyticsMisconfigured()).toBe(true)
+ expect(warn).not.toHaveBeenCalled()
+ })
+
+ it('warns in development when the token is missing', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
+ vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', '')
+ vi.stubEnv('NODE_ENV', 'development')
+ expect(warnIfAnalyticsMisconfigured()).toBe(false)
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining(POSTHOG_TOKEN_VAR))
+ })
+
+ it('stays silent in production when the token is missing', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
+ vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', '')
+ vi.stubEnv('NODE_ENV', 'production')
+ expect(warnIfAnalyticsMisconfigured()).toBe(false)
+ expect(warn).not.toHaveBeenCalled()
+ })
+
+ it('stays silent on self-hosted: off is deliberate, not a misconfiguration', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
+ vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', '')
+ vi.stubEnv('NODE_ENV', 'development')
+ expect(warnIfAnalyticsMisconfigured()).toBe(false)
+ expect(warn).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/lib/analytics/__tests__/properties.test.ts b/lib/analytics/__tests__/properties.test.ts
new file mode 100644
index 00000000..50a12c3e
--- /dev/null
+++ b/lib/analytics/__tests__/properties.test.ts
@@ -0,0 +1,78 @@
+import { describe, it, expect } from 'vitest'
+import { buildPersonProperties, buildGroupProperties } from '../properties'
+
+describe('buildPersonProperties', () => {
+ it('carries email, name and role', () => {
+ expect(
+ buildPersonProperties({
+ userId: 'u1',
+ email: 'jakob@example.se',
+ fullName: 'Jakob Wennberg',
+ role: 'owner',
+ })
+ ).toEqual({ email: 'jakob@example.se', name: 'Jakob Wennberg', role: 'owner' })
+ })
+
+ it('omits missing fields instead of sending nulls', () => {
+ expect(buildPersonProperties({ userId: 'u1', email: null, fullName: undefined })).toEqual({})
+ })
+
+ it('never echoes the userId into properties (it is the distinct id)', () => {
+ const props = buildPersonProperties({ userId: 'u1', email: 'a@b.se' })
+ expect(props).not.toHaveProperty('userId')
+ expect(props).not.toHaveProperty('user_id')
+ })
+})
+
+describe('buildGroupProperties', () => {
+ const company = {
+ id: 'c1',
+ name: 'Nordvik Bygg AB',
+ entityType: 'aktiebolag' as const,
+ accountingFramework: 'k2' as const,
+ paysSalaries: true,
+ trialEndsAt: '2026-08-01',
+ capabilities: ['ai', 'salary'],
+ }
+
+ it('carries company-shaped facts', () => {
+ expect(buildGroupProperties(company)).toEqual({
+ name: 'Nordvik Bygg AB',
+ entity_type: 'aktiebolag',
+ accounting_framework: 'k2',
+ pays_salaries: true,
+ trial_ends_at: '2026-08-01',
+ capabilities: ['ai', 'salary'],
+ })
+ })
+
+ // The load-bearing privacy assertion: for an enskild firma the
+ // organisationsnummer IS the owner's personnummer.
+ it('never sends org_number, whatever is passed in', () => {
+ const props = buildGroupProperties({
+ ...company,
+ // @ts-expect-error deliberately passing a field the type forbids
+ org_number: '556677-8899',
+ orgNumber: '556677-8899',
+ })
+ expect(props).not.toHaveProperty('org_number')
+ expect(props).not.toHaveProperty('orgNumber')
+ expect(JSON.stringify(props)).not.toContain('556677')
+ })
+
+ it('sorts capabilities so ordering churn is not seen as a change', () => {
+ const a = buildGroupProperties({ ...company, capabilities: ['salary', 'ai'] })
+ const b = buildGroupProperties({ ...company, capabilities: ['ai', 'salary'] })
+ expect(a.capabilities).toEqual(b.capabilities)
+ })
+
+ it('does not mutate the caller capabilities array', () => {
+ const capabilities = ['salary', 'ai']
+ buildGroupProperties({ ...company, capabilities })
+ expect(capabilities).toEqual(['salary', 'ai'])
+ })
+
+ it('omits absent optional fields', () => {
+ expect(buildGroupProperties({ id: 'c1', name: 'Ensam EF' })).toEqual({ name: 'Ensam EF' })
+ })
+})
diff --git a/lib/analytics/enabled.ts b/lib/analytics/enabled.ts
new file mode 100644
index 00000000..ade5ebd7
--- /dev/null
+++ b/lib/analytics/enabled.ts
@@ -0,0 +1,48 @@
+/**
+ * PostHog analytics gate.
+ *
+ * Analytics is a HOSTED-ONLY feature. Self-hosted deployments never load
+ * PostHog: an AGPL operator running their own instance should not have their
+ * users' behaviour shipped to our project, and the token is not baked into
+ * the Docker image (no `__NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN__` sentinel in
+ * Dockerfile / docker-entrypoint.sh, deliberately). Recapt achieved the same
+ * result only by accident, via that missing sentinel; here it is explicit.
+ *
+ * Mirrors the shape of `isBankIdEnabled()` in lib/auth/bankid.ts: self-hosted
+ * short-circuits first, then the feature's own env var decides.
+ */
+
+export const POSTHOG_TOKEN_VAR = 'NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN'
+
+export function isAnalyticsEnabled(): boolean {
+ if (process.env.NEXT_PUBLIC_SELF_HOSTED === 'true') return false
+ return Boolean(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN)
+}
+
+/**
+ * A missing token must never break the app, but it must not fail silently
+ * either: without it every capture is dropped and the integration looks
+ * healthy while zero events arrive (PostHog's own framework rule). So we warn
+ * loudly in development and stay a no-op in production.
+ *
+ * Returns true when the caller should proceed with initialisation.
+ */
+export function warnIfAnalyticsMisconfigured(): boolean {
+ if (isAnalyticsEnabled()) return true
+
+ // Self-hosted is a deliberate off, not a misconfiguration: stay quiet.
+ if (process.env.NEXT_PUBLIC_SELF_HOSTED === 'true') return false
+
+ if (process.env.NODE_ENV === 'development') {
+ // console, not createLogger(): this runs in the browser from
+ // instrumentation-client.ts before anything else is wired, and the point
+ // is for a developer to see it in the devtools console immediately.
+ // eslint-disable-next-line no-console
+ console.warn(
+ `${POSTHOG_TOKEN_VAR} variable required by PostHog is missing or un-configured, ` +
+ `this causes events to be silently missed. This error stops appearing once ` +
+ `${POSTHOG_TOKEN_VAR} is configured`
+ )
+ }
+ return false
+}
diff --git a/lib/analytics/posthog-observability.ts b/lib/analytics/posthog-observability.ts
new file mode 100644
index 00000000..f83a1a02
--- /dev/null
+++ b/lib/analytics/posthog-observability.ts
@@ -0,0 +1,87 @@
+import type { ObservabilityContext, ObservabilityLevel, ObservabilitySink } from '@/lib/observability'
+import { getPostHogServer, flushAnalytics } from './posthog-server'
+
+/**
+ * PostHog adapter for the provider-agnostic observability sink.
+ *
+ * Registering this is what makes `lib/observability` stop being a no-op: from
+ * then on every `error`-level line written through `createLogger()`, plus
+ * anything flagged `alert: true`, lands in PostHog Error Tracking already
+ * correlated with the log line's request context. That is far broader
+ * coverage than the `onRequestError` hook in instrumentation.ts, which only
+ * sees errors that escape uncaught.
+ *
+ * Redaction: `lib/observability/sink.ts` redacts both the error and the
+ * context before calling an adapter (and the logger has already redacted
+ * once; redaction is idempotent). This adapter therefore forwards what it is
+ * given and must never re-widen it by reaching for un-redacted sources.
+ *
+ * The interface requires captureException/captureMessage to be SYNCHRONOUS
+ * and to never throw. posthog-node's capture enqueues synchronously and
+ * sends afterwards, which fits: `flush()` is the drain the interface asks
+ * for, and callers on serverless paths await it.
+ */
+
+/** PostHog Error Tracking has no severity axis, so level rides as a property. */
+function toProperties(
+ context: ObservabilityContext,
+ extra?: Record
+): Record {
+ return { ...context, ...extra }
+}
+
+/**
+ * `distinct_id` is a reserved context key set by the logger when a request
+ * has an identified user. Without it PostHog would attribute the event to a
+ * generated id per event, which fragments the error's person view.
+ */
+function distinctIdFrom(context: ObservabilityContext): string | undefined {
+ const raw = context.distinct_id ?? context.user_id ?? context.userId
+ return typeof raw === 'string' && raw.length > 0 ? raw : undefined
+}
+
+export const postHogSink: ObservabilitySink = {
+ name: 'posthog',
+
+ captureException(error: unknown, context: ObservabilityContext): void {
+ try {
+ const posthog = getPostHogServer()
+ if (!posthog) return
+ posthog.captureException(
+ error instanceof Error ? error : new Error(String(error)),
+ distinctIdFrom(context),
+ toProperties(context)
+ )
+ } catch {
+ // Contract: must not throw.
+ }
+ },
+
+ captureMessage(message: string, level: ObservabilityLevel, context: ObservabilityContext): void {
+ try {
+ const posthog = getPostHogServer()
+ if (!posthog) return
+ const distinctId = distinctIdFrom(context)
+ posthog.capture({
+ // PostHog requires a distinct id; fall back to a stable server marker
+ // rather than inventing a per-event id, which would create a new
+ // person for every log line.
+ distinctId: distinctId ?? 'server',
+ event: '$log',
+ properties: toProperties(context, { message, level }),
+ })
+ } catch {
+ // Contract: must not throw.
+ }
+ },
+
+ async flush(): Promise {
+ try {
+ await flushAnalytics()
+ return true
+ } catch {
+ // Contract: must never reject.
+ return false
+ }
+ },
+}
diff --git a/lib/analytics/posthog-server.ts b/lib/analytics/posthog-server.ts
new file mode 100644
index 00000000..9b0e0f6c
--- /dev/null
+++ b/lib/analytics/posthog-server.ts
@@ -0,0 +1,47 @@
+import 'server-only'
+import { PostHog } from 'posthog-node'
+import { isAnalyticsEnabled } from './enabled'
+
+/**
+ * Server-side PostHog client (posthog-node).
+ *
+ * posthog-js is browser-only, so anything captured from a route handler, a
+ * server component or the onRequestError hook goes through here.
+ *
+ * `flushAt: 1` / `flushInterval: 0` are NOT tuning: Vercel functions are torn
+ * down per invocation, and the SDK's default batching would let the process
+ * die before the enqueued event is sent. With these set, every capture sends
+ * on its own, and callers must still `await flushAnalytics()` before
+ * returning or the send races the teardown.
+ *
+ * Returns null when analytics is off (self-hosted, or no token), so callers
+ * degrade to a no-op instead of throwing.
+ */
+let client: PostHog | null = null
+
+export function getPostHogServer(): PostHog | null {
+ if (!isAnalyticsEnabled()) return null
+ if (!client) {
+ client = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
+ // Server-side traffic is not ad-blocked, so it talks to PostHog
+ // directly rather than through the /rl same-origin rewrite (which only
+ // exists in the browser's Next.js routing anyway). This is the only
+ // reader of NEXT_PUBLIC_POSTHOG_HOST; the EU default keeps it optional.
+ host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com',
+ flushAt: 1,
+ flushInterval: 0,
+ enableExceptionAutocapture: true,
+ })
+ }
+ return client
+}
+
+/** Await this before a request handler returns, or the send is dropped. */
+export async function flushAnalytics(): Promise {
+ if (!client) return
+ try {
+ await client.flush()
+ } catch {
+ // Telemetry must never fail a real request.
+ }
+}
diff --git a/lib/analytics/properties.ts b/lib/analytics/properties.ts
new file mode 100644
index 00000000..bf3b836a
--- /dev/null
+++ b/lib/analytics/properties.ts
@@ -0,0 +1,78 @@
+import type { AccountingFramework, CompanyRole, EntityType } from '@/types'
+
+/**
+ * Property builders for PostHog identify + group calls.
+ *
+ * Pure functions on purpose: the repo's Vitest runs in a `node` environment
+ * and `vitest.config.ts` only matches `*.test.ts`, so component JSX is
+ * untestable by convention. Keeping the property shaping here means the part
+ * that can actually be wrong (what we send about a person and a company) is
+ * covered by unit tests, while the mounting component stays trivial.
+ *
+ * Two rules encoded here, both from PostHog's framework guidance:
+ *
+ * 1. PII belongs in identify() PERSON properties, never in capture() event
+ * properties. Only `buildPersonProperties` carries email/name.
+ * 2. `org_number` is deliberately NEVER sent. For an enskild firma the
+ * organisationsnummer IS the owner's personnummer, so it is not a company
+ * identifier at all: it is a national identity number for a natural
+ * person. `company_id` is the group key and is already unique.
+ */
+
+export interface AnalyticsUserInput {
+ userId: string
+ email?: string | null
+ fullName?: string | null
+ role?: CompanyRole | null
+}
+
+export interface AnalyticsCompanyInput {
+ id: string
+ name: string
+ entityType?: EntityType | null
+ accountingFramework?: AccountingFramework | null
+ paysSalaries?: boolean | null
+ trialEndsAt?: string | null
+ capabilities?: readonly string[]
+}
+
+/** Drop null/undefined so PostHog person properties don't fill with empties. */
+function compact(input: Record): Record {
+ return Object.fromEntries(
+ Object.entries(input).filter(([, value]) => value !== null && value !== undefined)
+ )
+}
+
+/**
+ * Person properties for `posthog.identify(userId, props)`.
+ *
+ * Matches what the privacy policy discloses is transferred: user id, email
+ * address and company name. Do not widen this without updating
+ * app/(public)/privacy/page.tsx and .compliance/ropa.yaml.
+ */
+export function buildPersonProperties(user: AnalyticsUserInput): Record {
+ return compact({
+ email: user.email,
+ name: user.fullName,
+ role: user.role,
+ })
+}
+
+/**
+ * Group properties for `posthog.group('company', companyId, props)`.
+ *
+ * Company-shaped facts only: anything that varies per user (role) belongs on
+ * the person, not the group. No org_number, see the file header.
+ */
+export function buildGroupProperties(company: AnalyticsCompanyInput): Record {
+ return compact({
+ name: company.name,
+ entity_type: company.entityType,
+ accounting_framework: company.accountingFramework,
+ pays_salaries: company.paysSalaries,
+ trial_ends_at: company.trialEndsAt,
+ // Sorted so the same entitlement set doesn't look like a change every
+ // time the underlying query returns a different order.
+ capabilities: company.capabilities ? [...company.capabilities].sort() : undefined,
+ })
+}
diff --git a/lib/analytics/reset.ts b/lib/analytics/reset.ts
new file mode 100644
index 00000000..a3aa77c8
--- /dev/null
+++ b/lib/analytics/reset.ts
@@ -0,0 +1,27 @@
+import posthog from 'posthog-js'
+import { isAnalyticsEnabled } from './enabled'
+
+/**
+ * Detach the current person from PostHog on logout.
+ *
+ * Call this ONLY on the transition out of an identified session, never on an
+ * initially anonymous page load: reset() discards the anonymous distinct id
+ * and the history attached to it, so a stray call at boot would sever the
+ * pre-login part of a signup funnel.
+ *
+ * Replaces clearRecaptIdentity() from lib/recapt.ts. That helper also had to
+ * sweep localStorage by key prefix, because Recapt cached the uid there. We
+ * run with `persistence: 'memory'`, so there is no cached identity to wipe:
+ * reset() is sufficient. (Surveys' own `seenSurvey_*` flags are deliberately
+ * left alone: they carry no identity, only "this browser already saw this
+ * survey", and clearing them would re-prompt the next person on the device.)
+ */
+export function resetAnalyticsIdentity(): void {
+ if (typeof window === 'undefined') return
+ if (!isAnalyticsEnabled()) return
+ try {
+ posthog.reset()
+ } catch {
+ // best-effort: we're already in a logout flow
+ }
+}
diff --git a/lib/init.ts b/lib/init.ts
index 647b5190..4fbaa588 100644
--- a/lib/init.ts
+++ b/lib/init.ts
@@ -4,6 +4,9 @@ import { createExtensionContext } from '@/lib/extensions/context-factory'
import { registerSupplierInvoiceHandler } from '@/lib/bookkeeping/handlers/supplier-invoice-handler'
import { registerEventLogHandler } from '@/lib/events/handlers/event-log-handler'
import { registerWebhookHandler } from '@/lib/webhooks/handler'
+import { registerObservabilitySink } from '@/lib/observability'
+import { postHogSink } from '@/lib/analytics/posthog-observability'
+import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
import { createLogger } from '@/lib/logger'
const log = createLogger('init')
@@ -70,6 +73,13 @@ export function ensureInitialized(): void {
validateEnvironment()
setContextFactory(createExtensionContext)
+ // Turns lib/observability from a no-op into PostHog Error Tracking. Gated,
+ // so with no token (core, CI, self-hosted) the sink stays the no-op and
+ // PostHog is never constructed and never contacted. Note the SDK is still
+ // BUNDLED in those builds: the imports are static, so the bytes ship even
+ // though nothing initialises. Making that a true zero would mean dynamic
+ // imports at every posthog call site, which is a deliberate non-goal here.
+ if (isAnalyticsEnabled()) registerObservabilitySink(postHogSink)
registerSupplierInvoiceHandler()
registerEventLogHandler()
registerWebhookHandler()
diff --git a/next.config.ts b/next.config.ts
index 28c796c4..828b8a7c 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -65,9 +65,42 @@ const nextConfig: NextConfig = {
turbopack: {
root: projectRoot,
},
+ // PostHog sends trailing-slash API requests; without this Next 308s them
+ // and the events are lost. Required by the reverse proxy below.
+ skipTrailingSlashRedirect: true,
experimental: {
optimizePackageImports: ['recharts', 'date-fns', 'framer-motion'],
},
+ // PostHog reverse proxy. Keeping analytics same-origin buys three things:
+ // the strict CSP below needs NO posthog hosts (`connect-src 'self'` already
+ // covers ingestion, `script-src 'self'` the lazy-loaded replay/survey
+ // bundles), tracking blockers have no third-party host to match, and the
+ // Recapt host allowlist is replaced by nothing at all.
+ //
+ // `/rl` is deliberately meaningless: PostHog's own guidance is that obvious
+ // prefixes (/analytics, /tracking, /telemetry, /posthog, and increasingly
+ // /ingest) are on blocker filter lists. It must stay in sync with `api_host`
+ // in instrumentation-client.ts AND with the matcher exclusion in proxy.ts,
+ // or middleware redirects the ingestion POSTs to /login.
+ //
+ // Both /static/* and /array/* must point at the ASSETS origin, not the
+ // ingestion origin: array/ serves the config bundle and is easy to miss.
+ async rewrites() {
+ return [
+ {
+ source: '/rl/static/:path*',
+ destination: 'https://eu-assets.i.posthog.com/static/:path*',
+ },
+ {
+ source: '/rl/array/:path*',
+ destination: 'https://eu-assets.i.posthog.com/array/:path*',
+ },
+ {
+ source: '/rl/:path*',
+ destination: 'https://eu.i.posthog.com/:path*',
+ },
+ ]
+ },
async redirects() {
const appUrlForRedirect = process.env.NEXT_PUBLIC_APP_URL?.trim().replace(/\/$/, '')
return [
diff --git a/package-lock.json b/package-lock.json
index 27249aee..892a3a94 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -44,6 +44,8 @@
"next-intl": "^4.13.2",
"next-themes": "^0.4.6",
"pdf-lib": "^1.17.1",
+ "posthog-js": "^1.407.3",
+ "posthog-node": "^5.46.1",
"qrcode": "^1.5.4",
"react": "19.2.7",
"react-dom": "19.2.7",
@@ -3784,6 +3786,31 @@
"pako": "^1.0.10"
}
},
+ "node_modules/@posthog/browser-common": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.2.2.tgz",
+ "integrity": "sha512-4NHumu2lx7pMeucDLfXnDmeP5CV2oVWY8jBpXBwBUjoYwkQxB7Z3A6q9oDydw+/iSAGl6MaPqW9gsnRK8AvqLA==",
+ "license": "MIT",
+ "dependencies": {
+ "@posthog/core": "^1.45.1",
+ "@posthog/types": "^1.398.0"
+ }
+ },
+ "node_modules/@posthog/core": {
+ "version": "1.45.1",
+ "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.45.1.tgz",
+ "integrity": "sha512-tLtvzomavb2PPWdGYKsusyIzIeL2Px47v348Smibkay7sMy/83TyPk+Ptsp2NdeOgJsbuwSxWkR2+XA0aSCAaA==",
+ "license": "MIT",
+ "dependencies": {
+ "@posthog/types": "^1.398.0"
+ }
+ },
+ "node_modules/@posthog/types": {
+ "version": "1.398.0",
+ "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.398.0.tgz",
+ "integrity": "sha512-sJMkl4k+u8yS/0fjHsKqE9xTdsAh30a2WvgChiptellnVoE0e8QJKFgqOMD2sk8FaEArPdeFklAhXvmENAt3Sg==",
+ "license": "MIT"
+ },
"node_modules/@radix-ui/number": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz",
@@ -7720,6 +7747,13 @@
"sharp": "*"
}
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -9283,6 +9317,17 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/core-js": {
+ "version": "3.49.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
+ "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -9714,6 +9759,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
+ "node_modules/dompurify": {
+ "version": "3.4.12",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
+ "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -14318,6 +14372,67 @@
"node": ">=0.10.0"
}
},
+ "node_modules/posthog-js": {
+ "version": "1.407.3",
+ "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.407.3.tgz",
+ "integrity": "sha512-b9Kc7HVN6nh+xsh1JrcLYHv9bbYLwYUM9belJtNVUFZSJWWfFcZRJJP6L+KeanAeu2VxSFSpioVA39pjjolv4A==",
+ "license": "(Apache-2.0 AND MIT)",
+ "dependencies": {
+ "@posthog/browser-common": "^0.2.2",
+ "@posthog/core": "^1.45.1",
+ "@posthog/types": "^1.398.0",
+ "core-js": "^3.49.0",
+ "dompurify": "^3.3.2",
+ "fflate": "^0.4.8",
+ "preact": "^10.29.3",
+ "query-selector-shadow-dom": "^1.0.1",
+ "web-vitals": "^5.3.0"
+ }
+ },
+ "node_modules/posthog-js/node_modules/fflate": {
+ "version": "0.4.9",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz",
+ "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==",
+ "license": "MIT"
+ },
+ "node_modules/posthog-node": {
+ "version": "5.46.1",
+ "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.46.1.tgz",
+ "integrity": "sha512-WjCqExq44pBdyg9MSsH6UAE0tNZ88p4aIuVFicgqhjf2Fbws6IhS4ioYUa4aBrbUPS9EDRXtBTtF5DpP1ml8Pw==",
+ "license": "MIT",
+ "dependencies": {
+ "@posthog/core": "^1.45.1"
+ },
+ "engines": {
+ "node": "^20.20.0 || >=22.22.0"
+ },
+ "peerDependencies": {
+ "rxjs": "^7.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rxjs": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/preact": {
+ "version": "10.29.7",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz",
+ "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ },
+ "peerDependencies": {
+ "preact-render-to-string": ">=5"
+ },
+ "peerDependenciesMeta": {
+ "preact-render-to-string": {
+ "optional": true
+ }
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -14397,6 +14512,12 @@
"node": ">=10.13.0"
}
},
+ "node_modules/query-selector-shadow-dom": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
+ "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==",
+ "license": "MIT"
+ },
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
@@ -16891,6 +17012,12 @@
"node": ">= 16"
}
},
+ "node_modules/web-vitals": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
+ "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
+ "license": "Apache-2.0"
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
diff --git a/package.json b/package.json
index a639a149..fb793512 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,8 @@
"next-intl": "^4.13.2",
"next-themes": "^0.4.6",
"pdf-lib": "^1.17.1",
+ "posthog-js": "^1.407.3",
+ "posthog-node": "^5.46.1",
"qrcode": "^1.5.4",
"react": "19.2.7",
"react-dom": "19.2.7",
diff --git a/proxy.ts b/proxy.ts
index 28fbf485..257c895e 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -17,7 +17,15 @@ export const config = {
* NOTE: `/api` is intentionally INCLUDED so the proxy can enforce the MFA
* (AAL2) gate on cookie-authenticated API calls (updateSession short-
* circuits API routes after that check: see lib/supabase/middleware.ts).
+ *
+ * `/rl` is the PostHog reverse-proxy prefix (rewrites in next.config.ts).
+ * It MUST be excluded: middleware runs BEFORE next.config rewrites, so
+ * without this updateSession() treats an ingestion POST as an unknown
+ * protected path and 307s it to /login. That silently kills analytics on
+ * every logged-out page and, because flags and asset loads still succeed
+ * through the rewrite, the integration looks healthy while no events
+ * arrive. Keep in sync with `api_host` in instrumentation-client.ts.
*/
- '/((?!_next/static|_next/image|favicon.ico|\\.well-known|sw\\.js|sw-register\\.js|manifest\\.json|manifest\\.webmanifest|icons/|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|js|json)$).*)',
+ '/((?!_next/static|_next/image|favicon.ico|\\.well-known|rl/|sw\\.js|sw-register\\.js|manifest\\.json|manifest\\.webmanifest|icons/|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|js|json)$).*)',
],
}
diff --git a/tests/stubs/server-only.ts b/tests/stubs/server-only.ts
new file mode 100644
index 00000000..b4542714
--- /dev/null
+++ b/tests/stubs/server-only.ts
@@ -0,0 +1,14 @@
+/**
+ * Test stub for the `server-only` package.
+ *
+ * `server-only` is a Next.js BUILD-time guard: its real entry point throws
+ * unconditionally, and the bundler swaps in a harmless module for the server
+ * graph. Vitest has no such bundler step, so any module that imports it
+ * (lib/analytics/posthog-server.ts, app/(dashboard)/request-context.ts)
+ * explodes with "This module cannot be imported from a Client Component
+ * module" the moment a test touches it, directly or transitively.
+ *
+ * Aliasing it to this empty module in vitest.config.ts keeps the guard doing
+ * its real job in `next build` while letting the suite import server modules.
+ */
+export {}
diff --git a/vitest.config.ts b/vitest.config.ts
index 031d8771..5a02c730 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,7 +1,13 @@
import { defineConfig } from 'vitest/config'
import path from 'path'
-const alias = { '@': path.resolve(__dirname, '.') }
+const alias = {
+ '@': path.resolve(__dirname, '.'),
+ // `server-only` is a build-time guard whose real entry point always throws;
+ // Next.js swaps it out during bundling, Vitest cannot. Without this stub any
+ // test that transitively imports a server-only module fails at import time.
+ 'server-only': path.resolve(__dirname, 'tests/stubs/server-only.ts'),
+}
const unitProject = {
resolve: { alias },