@posthog/next 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/LICENSE +299 -0
  2. package/README.md +122 -0
  3. package/dist/app/PostHogProvider.d.ts +62 -0
  4. package/dist/app/PostHogProvider.d.ts.map +1 -0
  5. package/dist/app/PostHogProvider.js +67 -0
  6. package/dist/app/PostHogProvider.js.map +1 -0
  7. package/dist/client/ClientPostHogProvider.d.ts +28 -0
  8. package/dist/client/ClientPostHogProvider.d.ts.map +1 -0
  9. package/dist/client/ClientPostHogProvider.js +34 -0
  10. package/dist/client/ClientPostHogProvider.js.map +1 -0
  11. package/dist/client/PostHogPageView.d.ts +30 -0
  12. package/dist/client/PostHogPageView.d.ts.map +1 -0
  13. package/dist/client/PostHogPageView.js +54 -0
  14. package/dist/client/PostHogPageView.js.map +1 -0
  15. package/dist/client/hooks.d.ts +2 -0
  16. package/dist/client/hooks.d.ts.map +1 -0
  17. package/dist/client/hooks.js +3 -0
  18. package/dist/client/hooks.js.map +1 -0
  19. package/dist/index.client.d.ts +6 -0
  20. package/dist/index.client.d.ts.map +1 -0
  21. package/dist/index.client.js +6 -0
  22. package/dist/index.client.js.map +1 -0
  23. package/dist/index.d.ts +9 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.edge.d.ts +7 -0
  26. package/dist/index.edge.d.ts.map +1 -0
  27. package/dist/index.edge.js +7 -0
  28. package/dist/index.edge.js.map +1 -0
  29. package/dist/index.js +7 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/index.react-server.d.ts +5 -0
  32. package/dist/index.react-server.d.ts.map +1 -0
  33. package/dist/index.react-server.js +6 -0
  34. package/dist/index.react-server.js.map +1 -0
  35. package/dist/middleware/postHogMiddleware.d.ts +95 -0
  36. package/dist/middleware/postHogMiddleware.d.ts.map +1 -0
  37. package/dist/middleware/postHogMiddleware.js +88 -0
  38. package/dist/middleware/postHogMiddleware.js.map +1 -0
  39. package/dist/pages/PostHogPageView.d.ts +25 -0
  40. package/dist/pages/PostHogPageView.d.ts.map +1 -0
  41. package/dist/pages/PostHogPageView.js +38 -0
  42. package/dist/pages/PostHogPageView.js.map +1 -0
  43. package/dist/pages/PostHogProvider.d.ts +16 -0
  44. package/dist/pages/PostHogProvider.d.ts.map +1 -0
  45. package/dist/pages/PostHogProvider.js +38 -0
  46. package/dist/pages/PostHogProvider.js.map +1 -0
  47. package/dist/pages/getServerSidePostHog.d.ts +27 -0
  48. package/dist/pages/getServerSidePostHog.d.ts.map +1 -0
  49. package/dist/pages/getServerSidePostHog.js +40 -0
  50. package/dist/pages/getServerSidePostHog.js.map +1 -0
  51. package/dist/pages.d.ts +9 -0
  52. package/dist/pages.d.ts.map +1 -0
  53. package/dist/pages.js +7 -0
  54. package/dist/pages.js.map +1 -0
  55. package/dist/server/getPostHog.d.ts +29 -0
  56. package/dist/server/getPostHog.d.ts.map +1 -0
  57. package/dist/server/getPostHog.js +61 -0
  58. package/dist/server/getPostHog.js.map +1 -0
  59. package/dist/server/nodeClientCache.d.ts +14 -0
  60. package/dist/server/nodeClientCache.d.ts.map +1 -0
  61. package/dist/server/nodeClientCache.js +33 -0
  62. package/dist/server/nodeClientCache.js.map +1 -0
  63. package/dist/shared/config.d.ts +36 -0
  64. package/dist/shared/config.d.ts.map +1 -0
  65. package/dist/shared/config.js +35 -0
  66. package/dist/shared/config.js.map +1 -0
  67. package/dist/shared/constants.d.ts +6 -0
  68. package/dist/shared/constants.d.ts.map +1 -0
  69. package/dist/shared/constants.js +6 -0
  70. package/dist/shared/constants.js.map +1 -0
  71. package/dist/shared/cookie.d.ts +69 -0
  72. package/dist/shared/cookie.d.ts.map +1 -0
  73. package/dist/shared/cookie.js +126 -0
  74. package/dist/shared/cookie.js.map +1 -0
  75. package/dist/shared/identity.d.ts +6 -0
  76. package/dist/shared/identity.d.ts.map +1 -0
  77. package/dist/shared/identity.js +9 -0
  78. package/dist/shared/identity.js.map +1 -0
  79. package/package.json +89 -0
  80. package/src/app/PostHogProvider.tsx +141 -0
  81. package/src/client/ClientPostHogProvider.tsx +51 -0
  82. package/src/client/PostHogPageView.tsx +63 -0
  83. package/src/client/hooks.ts +8 -0
  84. package/src/index.client.ts +9 -0
  85. package/src/index.edge.ts +10 -0
  86. package/src/index.react-server.ts +7 -0
  87. package/src/index.ts +8 -0
  88. package/src/middleware/postHogMiddleware.ts +170 -0
  89. package/src/pages/PostHogPageView.tsx +41 -0
  90. package/src/pages/PostHogProvider.tsx +61 -0
  91. package/src/pages/getServerSidePostHog.ts +49 -0
  92. package/src/pages.ts +8 -0
  93. package/src/server/getPostHog.ts +66 -0
  94. package/src/server/nodeClientCache.ts +37 -0
  95. package/src/shared/config.ts +52 -0
  96. package/src/shared/constants.ts +5 -0
  97. package/src/shared/cookie.ts +162 -0
  98. package/src/shared/identity.ts +9 -0
@@ -0,0 +1,141 @@
1
+ import React from 'react'
2
+ import type { PostHogConfig } from 'posthog-js'
3
+ import { ClientPostHogProvider } from '../client/ClientPostHogProvider'
4
+ import type { BootstrapConfig } from '../client/ClientPostHogProvider'
5
+ import { cookies } from 'next/headers'
6
+ import type { PostHogOptions } from 'posthog-node'
7
+ import { getOrCreateNodeClient } from '../server/nodeClientCache'
8
+ import { NEXTJS_CLIENT_DEFAULTS, resolveApiKey } from '../shared/config'
9
+ import { readPostHogCookie, isOptedOut } from '../shared/cookie'
10
+
11
+ type AllFlagsOptions = {
12
+ groups?: Record<string, string>
13
+ personProperties?: Record<string, string>
14
+ groupProperties?: Record<string, Record<string, string>>
15
+ onlyEvaluateLocally?: boolean
16
+ disableGeoip?: boolean
17
+ flagKeys?: string[]
18
+ }
19
+
20
+ export interface BootstrapFlagsConfig {
21
+ /** Specific flag keys to evaluate. If omitted, evaluates all flags. */
22
+ flags?: string[]
23
+ /** Groups to evaluate flags for (e.g., `{ company: 'posthog' }`). */
24
+ groups?: AllFlagsOptions['groups']
25
+ /** Known person properties to use for flag evaluation. */
26
+ personProperties?: AllFlagsOptions['personProperties']
27
+ /** Known group properties to use for flag evaluation, keyed by group type. */
28
+ groupProperties?: AllFlagsOptions['groupProperties']
29
+ }
30
+
31
+ export interface PostHogProviderProps {
32
+ /**
33
+ * PostHog project API key (starts with phc_).
34
+ * If omitted, reads from `NEXT_PUBLIC_POSTHOG_KEY` env var.
35
+ */
36
+ apiKey?: string
37
+ /** Optional posthog-js configuration overrides. */
38
+ clientOptions?: Partial<PostHogConfig>
39
+ /** Options passed to the posthog-node client used for server-side flag evaluation. */
40
+ serverOptions?: Partial<PostHogOptions>
41
+ /**
42
+ * Enable server-side feature flag evaluation for bootstrap.
43
+ *
44
+ * When enabled, the provider calls `cookies()` to read the user's
45
+ * identity and evaluates flags via `posthog-node`. This opts the
46
+ * route into **dynamic rendering** (incompatible with static
47
+ * generation / ISR).
48
+ *
49
+ * When omitted or falsy (default), no dynamic APIs are called and
50
+ * the provider is safe for static rendering and PPR.
51
+ */
52
+ bootstrapFlags?: boolean | BootstrapFlagsConfig
53
+ children: React.ReactNode
54
+ }
55
+
56
+ /**
57
+ * PostHog provider for Next.js App Router.
58
+ *
59
+ * By default this component is **static-safe** — it does not call any
60
+ * dynamic APIs (`cookies()`, `headers()`) and is compatible with static
61
+ * generation, ISR, and Partial Prerendering (PPR).
62
+ *
63
+ * When `bootstrapFlags` is enabled, the provider evaluates feature flags
64
+ * on the server and bootstraps the client SDK, which opts the route into
65
+ * dynamic rendering.
66
+ *
67
+ * All PostHog hooks (`usePostHog`, `useFeatureFlagEnabled`, etc.)
68
+ * require this provider as an ancestor.
69
+ */
70
+ export async function PostHogProvider({
71
+ apiKey: apiKeyProp,
72
+ clientOptions,
73
+ serverOptions,
74
+ bootstrapFlags,
75
+ children,
76
+ }: PostHogProviderProps) {
77
+ const apiKey = resolveApiKey(apiKeyProp)
78
+ if (!apiKey.startsWith('phc_')) {
79
+ // eslint-disable-next-line no-console
80
+ console.warn(
81
+ `[PostHog Next.js] apiKey "${apiKey}" does not start with "phc_". This may not be a valid PostHog project API key.`
82
+ )
83
+ }
84
+
85
+ const host = clientOptions?.api_host ?? process.env.NEXT_PUBLIC_POSTHOG_HOST
86
+ const resolvedOptions: Partial<PostHogConfig> = {
87
+ ...NEXTJS_CLIENT_DEFAULTS,
88
+ ...clientOptions,
89
+ ...(host ? { api_host: host } : {}),
90
+ }
91
+
92
+ let bootstrap: BootstrapConfig | undefined
93
+
94
+ if (bootstrapFlags) {
95
+ try {
96
+ bootstrap = await evaluateFlags(apiKey, resolvedOptions, bootstrapFlags, serverOptions)
97
+
98
+ // Only disable the first-load fetch when we actually have bootstrap data.
99
+ // If evaluateFlags returned undefined (no cookie, opted-out), the client
100
+ // still needs to fetch flags on first load.
101
+ if (bootstrap) {
102
+ resolvedOptions.advanced_disable_feature_flags_on_first_load = true
103
+ }
104
+ } catch (error) {
105
+ // eslint-disable-next-line no-console
106
+ console.warn('[PostHog Next.js] Failed to evaluate bootstrap flags:', error)
107
+ }
108
+ }
109
+
110
+ return (
111
+ <ClientPostHogProvider apiKey={apiKey} options={resolvedOptions} bootstrap={bootstrap}>
112
+ {children}
113
+ </ClientPostHogProvider>
114
+ )
115
+ }
116
+
117
+ async function evaluateFlags(
118
+ apiKey: string,
119
+ options: Partial<PostHogConfig> | undefined,
120
+ bootstrapFlags: boolean | BootstrapFlagsConfig,
121
+ serverOptions?: Partial<PostHogOptions>
122
+ ): Promise<BootstrapConfig | undefined> {
123
+ const cookieStore = await cookies()
124
+
125
+ if (isOptedOut(cookieStore, apiKey, options)) {
126
+ return undefined
127
+ }
128
+
129
+ const cookieState = readPostHogCookie(cookieStore, apiKey)
130
+ if (!cookieState) {
131
+ return undefined
132
+ }
133
+
134
+ const host = serverOptions?.host ?? process.env.NEXT_PUBLIC_POSTHOG_HOST
135
+ const nodeOptions: Partial<PostHogOptions> = { ...serverOptions, ...(host ? { host } : {}) }
136
+ const client = await getOrCreateNodeClient(apiKey, nodeOptions)
137
+
138
+ const { flags: flagKeys, ...flagOptions } = typeof bootstrapFlags === 'object' ? bootstrapFlags : {}
139
+ const allFlagsOptions: AllFlagsOptions = { ...flagOptions, ...(flagKeys ? { flagKeys } : {}) }
140
+ return client.getAllFlagsAndPayloads(cookieState.distinctId, allFlagsOptions)
141
+ }
@@ -0,0 +1,51 @@
1
+ 'use client'
2
+
3
+ import React from 'react'
4
+ import posthogJs from 'posthog-js'
5
+ import { PostHogProvider as ReactPostHogProvider } from 'posthog-js/react'
6
+ import type { BootstrapConfig, PostHogConfig } from 'posthog-js'
7
+
8
+ export type { BootstrapConfig }
9
+
10
+ export interface ClientPostHogProviderProps {
11
+ /** PostHog project API key (starts with phc_) */
12
+ apiKey: string
13
+ /** Optional posthog-js configuration overrides */
14
+ options?: Partial<PostHogConfig>
15
+ /** Server-evaluated feature flag values to bootstrap the client SDK with */
16
+ bootstrap?: BootstrapConfig
17
+ children: React.ReactNode
18
+ }
19
+
20
+ /**
21
+ * Client-side PostHog provider with SSR bootstrap support.
22
+ *
23
+ * This is an internal component rendered by PostHogProvider (server component).
24
+ * It forwards bootstrap data to posthog-js so flag hooks return real values
25
+ * immediately without a network round-trip.
26
+ *
27
+ * We initialize posthog-js eagerly during render (client-side only) rather than
28
+ * deferring to a useEffect. React fires effects bottom-up, so child useEffects
29
+ * (e.g. a consent banner) would access the posthog instance before the parent
30
+ * provider's useEffect calls init(). By initializing during render and passing
31
+ * the `client` prop, we guarantee the instance is fully configured before any
32
+ * child code runs.
33
+ */
34
+ export function ClientPostHogProvider({ apiKey, options, bootstrap, children }: ClientPostHogProviderProps) {
35
+ if (!apiKey) {
36
+ // eslint-disable-next-line no-console
37
+ console.warn('[PostHog Next.js] apiKey is required — PostHog will not be initialized')
38
+ return <>{children}</>
39
+ }
40
+
41
+ const mergedOptions = bootstrap ? { ...options, bootstrap: { ...options?.bootstrap, ...bootstrap } } : options
42
+
43
+ // Initialize eagerly during render on the client so that child effects
44
+ // see a fully configured posthog instance. The `__loaded` guard prevents
45
+ // double-init (e.g. React StrictMode).
46
+ if (typeof window !== 'undefined' && !posthogJs.__loaded) {
47
+ posthogJs.init(apiKey, mergedOptions)
48
+ }
49
+
50
+ return <ReactPostHogProvider client={posthogJs}>{children}</ReactPostHogProvider>
51
+ }
@@ -0,0 +1,63 @@
1
+ 'use client'
2
+
3
+ import { Suspense, useEffect } from 'react'
4
+ import { usePathname, useSearchParams } from 'next/navigation'
5
+ import { usePostHog } from 'posthog-js/react'
6
+
7
+ /**
8
+ * Tracks pageviews on route change in Next.js App Router.
9
+ *
10
+ * Place this component inside your `PostHogProvider` (typically in `app/layout.tsx`).
11
+ * It will automatically capture a `$pageview` event whenever the route changes.
12
+ *
13
+ * Includes its own Suspense boundary (required by `useSearchParams()`), so you
14
+ * don't need to wrap it in one yourself.
15
+ *
16
+ * @example
17
+ * ```tsx
18
+ * // app/layout.tsx
19
+ * import { PostHogProvider, PostHogPageView } from '@posthog/next'
20
+ *
21
+ * export default function RootLayout({ children }: { children: React.ReactNode }) {
22
+ * return (
23
+ * <html>
24
+ * <body>
25
+ * <PostHogProvider apiKey={process.env.NEXT_PUBLIC_POSTHOG_KEY!}>
26
+ * <PostHogPageView />
27
+ * {children}
28
+ * </PostHogProvider>
29
+ * </body>
30
+ * </html>
31
+ * )
32
+ * }
33
+ * ```
34
+ */
35
+ export function PostHogPageView() {
36
+ return (
37
+ <Suspense fallback={null}>
38
+ <PageViewTracker />
39
+ </Suspense>
40
+ )
41
+ }
42
+
43
+ function PageViewTracker() {
44
+ const pathname = usePathname()
45
+ const searchParams = useSearchParams()
46
+ const posthog = usePostHog()
47
+
48
+ useEffect(() => {
49
+ if (!posthog) {
50
+ return
51
+ }
52
+
53
+ let url = pathname
54
+ const search = searchParams.toString()
55
+ if (search) {
56
+ url = `${pathname}?${search}`
57
+ }
58
+
59
+ posthog.capture('$pageview', { $current_url: url })
60
+ }, [pathname, searchParams, posthog])
61
+
62
+ return null
63
+ }
@@ -0,0 +1,8 @@
1
+ 'use client'
2
+
3
+ export {
4
+ usePostHog,
5
+ useFeatureFlagResult as useFeatureFlag,
6
+ useActiveFeatureFlags,
7
+ PostHogFeature,
8
+ } from 'posthog-js/react'
@@ -0,0 +1,9 @@
1
+ // Browser-safe exports. PostHogProvider (a server component) is excluded
2
+ // because it imports posthog-node which uses Node.js APIs.
3
+ export { PostHogPageView } from './client/PostHogPageView'
4
+ export { DEFAULT_INGEST_PATH } from './shared/constants'
5
+ export { usePostHog, useFeatureFlag, useActiveFeatureFlags, PostHogFeature } from './client/hooks'
6
+
7
+ // Re-export types (type-only, erased at build time)
8
+ export type { PostHogProviderProps, BootstrapFlagsConfig } from './app/PostHogProvider'
9
+ export type { PostHogMiddlewareOptions, PostHogProxyOptions } from './middleware/postHogMiddleware'
@@ -0,0 +1,10 @@
1
+ // Edge-runtime exports (middleware). Excludes PostHogProvider and
2
+ // posthog-node which require Node.js APIs.
3
+ export { postHogMiddleware } from './middleware/postHogMiddleware'
4
+ export { PostHogPageView } from './client/PostHogPageView'
5
+ export { DEFAULT_INGEST_PATH } from './shared/constants'
6
+ export { usePostHog, useFeatureFlag, useActiveFeatureFlags, PostHogFeature } from './client/hooks'
7
+
8
+ // Re-export types (type-only, erased at build time)
9
+ export type { PostHogProviderProps, BootstrapFlagsConfig } from './app/PostHogProvider'
10
+ export type { PostHogMiddlewareOptions, PostHogProxyOptions } from './middleware/postHogMiddleware'
@@ -0,0 +1,7 @@
1
+ // Server component exports (only available in react-server context)
2
+ export { PostHogProvider } from './app/PostHogProvider'
3
+ export type { PostHogProviderProps, BootstrapFlagsConfig } from './app/PostHogProvider'
4
+
5
+ // Client-safe exports (re-exported so server components can also import them)
6
+ export { PostHogPageView } from './client/PostHogPageView'
7
+ export { usePostHog, useFeatureFlag, useActiveFeatureFlags, PostHogFeature } from './client/hooks'
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export { PostHogProvider } from './app/PostHogProvider'
2
+ export { getPostHog } from './server/getPostHog'
3
+ export { postHogMiddleware } from './middleware/postHogMiddleware'
4
+ export { PostHogPageView } from './client/PostHogPageView'
5
+ export { DEFAULT_INGEST_PATH } from './shared/constants'
6
+ export { usePostHog, useFeatureFlag, useActiveFeatureFlags, PostHogFeature } from './client/hooks'
7
+ export type { PostHogProviderProps, BootstrapFlagsConfig } from './app/PostHogProvider'
8
+ export type { PostHogMiddlewareOptions, PostHogProxyOptions } from './middleware/postHogMiddleware'
@@ -0,0 +1,170 @@
1
+ import 'server-only'
2
+
3
+ import { NextResponse } from 'next/server'
4
+ import type { NextRequest } from 'next/server'
5
+ import { getPostHogCookieName, readPostHogCookie, serializePostHogCookie, isOptedOut } from '../shared/cookie'
6
+ import { generateAnonymousId } from '../shared/identity'
7
+ import { resolveApiKey } from '../shared/config'
8
+ import { COOKIE_MAX_AGE_SECONDS, DEFAULT_API_HOST, DEFAULT_INGEST_PATH } from '../shared/constants'
9
+
10
+ export interface PostHogProxyOptions {
11
+ /** Path prefix to intercept. Default: '/ingest'. */
12
+ pathPrefix?: string
13
+ /** PostHog ingest host to rewrite to. Default: 'https://us.i.posthog.com' */
14
+ host?: string
15
+ }
16
+
17
+ /**
18
+ * Configuration for the PostHog middleware.
19
+ */
20
+ export interface PostHogMiddlewareOptions {
21
+ /**
22
+ * PostHog project API key (starts with phc_).
23
+ * If omitted, reads from `NEXT_PUBLIC_POSTHOG_KEY` env var.
24
+ */
25
+ apiKey?: string
26
+ /** Cookie max age in seconds. Default: 365 days. */
27
+ cookieMaxAgeSeconds?: number
28
+ /**
29
+ * An existing response to seed the PostHog cookie on.
30
+ *
31
+ * When provided, the middleware seeds the identity cookie on this response
32
+ * instead of creating a new one via `NextResponse.next()`. This enables
33
+ * composition with other middleware.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * export default async function middleware(request: NextRequest) {
38
+ * const response = NextResponse.next()
39
+ * response.headers.set('x-custom', 'value')
40
+ * return postHogMiddleware({ response })(request)
41
+ * }
42
+ * ```
43
+ */
44
+ response?: NextResponse
45
+ /**
46
+ * When true, skips cookie seeding when no consent cookie is present.
47
+ * Mirrors the client-side `opt_out_capturing_by_default` option.
48
+ */
49
+ optOutByDefault?: boolean
50
+ /**
51
+ * Custom name for the consent cookie.
52
+ * Mirrors the client-side `consent_persistence_name` option.
53
+ */
54
+ consentCookieName?: string
55
+ /**
56
+ * Custom prefix for the consent cookie (appended with apiKey).
57
+ * Mirrors the client-side `opt_out_capturing_cookie_prefix` option.
58
+ */
59
+ consentCookiePrefix?: string
60
+ /**
61
+ * Proxy PostHog API requests through your app's domain.
62
+ *
63
+ * When enabled, requests matching the path prefix (default: `/ingest`)
64
+ * are rewritten to the PostHog ingest host, allowing SDK traffic to
65
+ * flow through your app's domain.
66
+ *
67
+ * Set to `true` for defaults, or pass an object to customize the path
68
+ * prefix and/or target host.
69
+ *
70
+ * When using the proxy, set `api_host` to the path prefix (e.g. `/ingest`)
71
+ * in your PostHogProvider options so the client SDK sends requests to
72
+ * your app's domain.
73
+ */
74
+ proxy?: boolean | PostHogProxyOptions
75
+ }
76
+
77
+ interface ResolvedRewriteConfig {
78
+ pathPrefix: string
79
+ host: string
80
+ }
81
+
82
+ function resolveProxyConfig(proxy: boolean | PostHogProxyOptions | undefined): ResolvedRewriteConfig | null {
83
+ if (!proxy) {
84
+ return null
85
+ }
86
+ const options = typeof proxy === 'object' ? proxy : {}
87
+ const prefix = options.pathPrefix ?? DEFAULT_INGEST_PATH
88
+ return {
89
+ pathPrefix: prefix.startsWith('/') ? prefix : `/${prefix}`,
90
+ host: options.host ?? DEFAULT_API_HOST,
91
+ }
92
+ }
93
+
94
+ function rewriteToPostHog(request: NextRequest, config: ResolvedRewriteConfig): NextResponse {
95
+ const pathname = request.nextUrl.pathname.slice(config.pathPrefix.length) || '/'
96
+ // eslint-disable-next-line compat/compat
97
+ const url = new URL(pathname, config.host)
98
+ url.search = request.nextUrl.search
99
+ return NextResponse.rewrite(url)
100
+ }
101
+
102
+ /**
103
+ * Creates a Next.js middleware that seeds the PostHog identity cookie
104
+ * on first visit and optionally rewrites API requests to PostHog's
105
+ * ingest host.
106
+ *
107
+ * @example Standalone (simplest — reads apiKey from NEXT_PUBLIC_POSTHOG_KEY)
108
+ * ```ts
109
+ * // middleware.ts
110
+ * import { postHogMiddleware } from '@posthog/next'
111
+ *
112
+ * export default postHogMiddleware({ proxy: true })
113
+ *
114
+ * export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }
115
+ * ```
116
+ *
117
+ * @example Composed with other middleware
118
+ * ```ts
119
+ * import { postHogMiddleware } from '@posthog/next'
120
+ *
121
+ * export default async function middleware(request: NextRequest) {
122
+ * const response = otherMiddleware(request)
123
+ * return postHogMiddleware({ proxy: true, response })(request)
124
+ * }
125
+ * ```
126
+ */
127
+ export function postHogMiddleware(config: PostHogMiddlewareOptions = {}) {
128
+ const apiKey = resolveApiKey(config.apiKey)
129
+ const proxyConfig = resolveProxyConfig(config.proxy)
130
+
131
+ return async function middleware(request: NextRequest) {
132
+ // Proxy ingest requests to PostHog's host. These are API calls
133
+ // from the browser SDK and don't need cookie seeding.
134
+ if (proxyConfig && request.nextUrl.pathname.startsWith(proxyConfig.pathPrefix)) {
135
+ return rewriteToPostHog(request, proxyConfig)
136
+ }
137
+
138
+ const cookieName = getPostHogCookieName(apiKey)
139
+ const state = readPostHogCookie(request.cookies, apiKey)
140
+ const response = config.response ?? NextResponse.next()
141
+
142
+ const optedOut = isOptedOut(request.cookies, apiKey, {
143
+ opt_out_capturing_by_default: config.optOutByDefault,
144
+ consent_persistence_name: config.consentCookieName,
145
+ opt_out_capturing_cookie_prefix: config.consentCookiePrefix,
146
+ })
147
+
148
+ if (optedOut) {
149
+ if (state) {
150
+ response.cookies.delete(cookieName)
151
+ }
152
+ return response
153
+ }
154
+
155
+ // Seed the PostHog cookie on first visit so server and client
156
+ // share the same identity from the first render.
157
+ if (!state) {
158
+ const distinctId = generateAnonymousId()
159
+ response.cookies.set(cookieName, serializePostHogCookie(distinctId), {
160
+ path: '/',
161
+ sameSite: 'lax',
162
+ secure: request.nextUrl.protocol === 'https:',
163
+ maxAge: config.cookieMaxAgeSeconds ?? COOKIE_MAX_AGE_SECONDS,
164
+ httpOnly: false,
165
+ })
166
+ }
167
+
168
+ return response
169
+ }
170
+ }
@@ -0,0 +1,41 @@
1
+ import { useEffect } from 'react'
2
+ import { useRouter } from 'next/router'
3
+ import { usePostHog } from 'posthog-js/react'
4
+
5
+ /**
6
+ * Tracks pageviews on route change in Next.js Pages Router.
7
+ *
8
+ * Place this component inside your `PostHogProvider` in `pages/_app.tsx`.
9
+ * It will automatically capture a `$pageview` event whenever the route changes.
10
+ *
11
+ * Uses `router.asPath` which includes query parameters and hash fragments.
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * // pages/_app.tsx
16
+ * import { PostHogProvider, PostHogPageView } from '@posthog/next/pages'
17
+ *
18
+ * export default function App({ Component, pageProps }: AppProps) {
19
+ * return (
20
+ * <PostHogProvider apiKey={process.env.NEXT_PUBLIC_POSTHOG_KEY!}>
21
+ * <PostHogPageView />
22
+ * <Component {...pageProps} />
23
+ * </PostHogProvider>
24
+ * )
25
+ * }
26
+ * ```
27
+ */
28
+ export function PostHogPageView() {
29
+ const router = useRouter()
30
+ const posthog = usePostHog()
31
+
32
+ useEffect(() => {
33
+ if (!posthog || !router.isReady) {
34
+ return
35
+ }
36
+
37
+ posthog.capture('$pageview', { $current_url: router.asPath })
38
+ }, [router.asPath, router.isReady, posthog])
39
+
40
+ return null
41
+ }
@@ -0,0 +1,61 @@
1
+ import React from 'react'
2
+ import type { PostHogConfig, BootstrapConfig } from 'posthog-js'
3
+ import { ClientPostHogProvider } from '../client/ClientPostHogProvider'
4
+ import { NEXTJS_CLIENT_DEFAULTS, resolveApiKey } from '../shared/config'
5
+
6
+ export interface PagesPostHogProviderProps {
7
+ /**
8
+ * PostHog project API key (starts with phc_).
9
+ * If omitted, reads from `NEXT_PUBLIC_POSTHOG_KEY` env var.
10
+ */
11
+ apiKey?: string
12
+ /** Optional posthog-js configuration overrides. */
13
+ clientOptions?: Partial<PostHogConfig>
14
+ /** Server-evaluated bootstrap data from getServerSidePostHog. */
15
+ bootstrap?: BootstrapConfig
16
+ children: React.ReactNode
17
+ }
18
+
19
+ /**
20
+ * PostHog provider for Next.js Pages Router.
21
+ *
22
+ * Place this in your `pages/_app.tsx` wrapping `<Component {...pageProps} />`.
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * import { PostHogProvider } from '@posthog/next/pages'
27
+ *
28
+ * export default function App({ Component, pageProps }: AppProps) {
29
+ * return (
30
+ * <PostHogProvider apiKey={process.env.NEXT_PUBLIC_POSTHOG_KEY!}>
31
+ * <Component {...pageProps} />
32
+ * </PostHogProvider>
33
+ * )
34
+ * }
35
+ * ```
36
+ */
37
+ let apiKeyWarned = false
38
+
39
+ export function PostHogProvider({ apiKey: apiKeyProp, clientOptions, bootstrap, children }: PagesPostHogProviderProps) {
40
+ const apiKey = resolveApiKey(apiKeyProp)
41
+ if (!apiKeyWarned && !apiKey.startsWith('phc_')) {
42
+ apiKeyWarned = true
43
+ // eslint-disable-next-line no-console
44
+ console.warn(
45
+ `[PostHog Next.js] apiKey "${apiKey}" does not start with "phc_". This may not be a valid PostHog project API key.`
46
+ )
47
+ }
48
+
49
+ const host = clientOptions?.api_host ?? process.env.NEXT_PUBLIC_POSTHOG_HOST
50
+ const resolvedOptions: Partial<PostHogConfig> = {
51
+ ...NEXTJS_CLIENT_DEFAULTS,
52
+ ...clientOptions,
53
+ ...(host ? { api_host: host } : {}),
54
+ }
55
+
56
+ return (
57
+ <ClientPostHogProvider apiKey={apiKey} options={resolvedOptions} bootstrap={bootstrap}>
58
+ {children}
59
+ </ClientPostHogProvider>
60
+ )
61
+ }
@@ -0,0 +1,49 @@
1
+ import type { GetServerSidePropsContext } from 'next'
2
+ import type { PostHogOptions, IPostHog } from 'posthog-node'
3
+ import { getOrCreateNodeClient } from '../server/nodeClientCache'
4
+ import { cookieStoreFromHeader, readPostHogCookie, cookieStateToProperties, isOptedOut } from '../shared/cookie'
5
+ import { resolveApiKey } from '../shared/config'
6
+
7
+ /**
8
+ * Creates a PostHog server client scoped to the current request.
9
+ *
10
+ * Reads the user's identity from the PostHog cookie in request headers
11
+ * and sets it as context via `enterContext()`. The returned client is
12
+ * ready to use — methods like `getAllFlags()`, `getFeatureFlagResult()`,
13
+ * and `capture()` automatically use the current user's identity.
14
+ *
15
+ * @param ctx - The Next.js GetServerSidePropsContext
16
+ * @param apiKey - PostHog project API key. If omitted, reads from NEXT_PUBLIC_POSTHOG_KEY.
17
+ * @param options - Optional posthog-node configuration
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * import { getServerSidePostHog } from '@posthog/next/pages'
22
+ *
23
+ * export const getServerSideProps: GetServerSideProps = async (ctx) => {
24
+ * const posthog = await getServerSidePostHog(ctx)
25
+ * const flags = await posthog.getAllFlagsAndPayloads()
26
+ * return { props: { posthogBootstrap: flags } }
27
+ * }
28
+ * ```
29
+ */
30
+ export async function getServerSidePostHog(
31
+ ctx: GetServerSidePropsContext,
32
+ apiKey?: string,
33
+ options?: Partial<PostHogOptions>
34
+ ): Promise<IPostHog> {
35
+ const resolvedApiKey = resolveApiKey(apiKey)
36
+ const host = options?.host ?? process.env.NEXT_PUBLIC_POSTHOG_HOST
37
+ const resolvedOptions = host ? { ...options, host } : options
38
+ const client = await getOrCreateNodeClient(resolvedApiKey, resolvedOptions)
39
+
40
+ const cookieStore = cookieStoreFromHeader(ctx.req.headers.cookie || '')
41
+
42
+ if (!isOptedOut(cookieStore, resolvedApiKey)) {
43
+ const state = readPostHogCookie(cookieStore, resolvedApiKey)
44
+ const properties = cookieStateToProperties(state)
45
+ client.enterContext({ distinctId: state?.distinctId, sessionId: state?.sessionId, properties })
46
+ }
47
+
48
+ return client
49
+ }
package/src/pages.ts ADDED
@@ -0,0 +1,8 @@
1
+ export { PostHogProvider } from './pages/PostHogProvider'
2
+ export { getServerSidePostHog } from './pages/getServerSidePostHog'
3
+ export { getPostHog } from './server/getPostHog'
4
+ export { postHogMiddleware } from './middleware/postHogMiddleware'
5
+ export { PostHogPageView } from './pages/PostHogPageView'
6
+ export { DEFAULT_INGEST_PATH } from './shared/constants'
7
+ export type { PagesPostHogProviderProps } from './pages/PostHogProvider'
8
+ export type { PostHogMiddlewareOptions, PostHogProxyOptions } from './middleware/postHogMiddleware'