Convex

Convex handles the database, server functions, and real-time sync for your app. PostHog covers what the database doesn't: signups, feature usage, exceptions, AI spend.

This page is the single landing point for using PostHog with Convex. Pick the surfaces you need from the table below. Most teams use three or four together.

Pick what you need

You want toUse
Capture events and evaluate feature flags from inside Convex functions@posthog/convex component
Stitch frontend and backend events into one user timelineFrontend + backend stitching
Trace LLM calls or @convex-dev/agent runsAI observability
Query your Convex tables alongside PostHog event dataConvex data warehouse source
Forward Convex logs to PostHog LogsConvex dashboard log streams (no code)
Forward Convex exceptions to PostHog Error TrackingConvex dashboard exception reporting (no code)

The first row is what most teams reach for first. The last two are configured in the Convex dashboard with no PostHog-side setup.

Convex dashboard PostHog Logs configuration form
Convex dashboard PostHog Error Tracking configuration form

Get your PostHog project token

Every integration on this page needs the same two values:

  • Project token, starts with phc_. Found in PostHog under Settings → Project → General → Project API Key.
  • Host, https://us.i.posthog.com for PostHog US Cloud, https://eu.i.posthog.com for EU Cloud, or your own URL for self-hosted PostHog.

Have those ready before starting any of the sections below.

Capture events and flags from your Convex code

The @posthog/convex component lets you capture events, identify users, evaluate feature flags, and forward exceptions directly from your Convex queries, mutations, and actions. Reach for this surface when you want analytics tied to specific server-side behavior.

1. Install the component

npm install @posthog/convex

Then register it in convex/convex.config.ts:

TypeScript
// convex/convex.config.ts
import { defineApp } from "convex/server"
import posthog from "@posthog/convex/convex.config.js"
const app = defineApp()
app.use(posthog)
export default app

2. Set environment variables

sh
npx convex env set POSTHOG_API_KEY phc_your_project_api_key
npx convex env set POSTHOG_HOST https://us.i.posthog.com

For local feature flag evaluation (covered in step 5), also set a feature flags secure API key:

sh
npx convex env set POSTHOG_PERSONAL_API_KEY phs_your_feature_flags_secure_api_key

3. Initialize the client

Create convex/posthog.ts. Every other backend function will import the posthog instance from here.

TypeScript
// convex/posthog.ts
import { PostHog } from "@posthog/convex"
import { components } from "./_generated/api"
export const posthog = new PostHog(components.posthog, {
apiKey: process.env.POSTHOG_API_KEY,
personalApiKey: process.env.POSTHOG_PERSONAL_API_KEY,
host: process.env.POSTHOG_HOST,
})

4. Capture events and identify users

capture and identify work in mutations and actions. They schedule the PostHog API call via ctx.scheduler.runAfter, so they return immediately without blocking the caller.

TypeScript
// convex/users.ts
import { mutation } from "./_generated/server"
import { v } from "convex/values"
import { getAuthUserId } from "@convex-dev/auth/server"
import { posthog } from "./posthog"
export const updateProfile = mutation({
args: { plan: v.string() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx)
if (userId === null) throw new Error("Not authenticated")
await ctx.db.patch(userId, { plan: args.plan })
await posthog.identify(ctx, {
distinctId: userId,
properties: { plan: args.plan },
})
await posthog.capture(ctx, {
distinctId: userId,
event: "plan_changed",
properties: { plan: args.plan },
})
},
})

Use the same Convex user ID as the distinctId everywhere. PostHog stitches events onto a single person timeline by matching distinct IDs across sources. See stitching frontend and backend below for the matching frontend setup.

5. Evaluate feature flags

@posthog/convex supports two flag evaluation paths. Default to local evaluation. It's faster (no network round-trip), works in queries (so reading a flag re-runs your query reactively when the flag changes), and works in mutations and actions too. Only reach for remote evaluation when local can't handle the flag (experience continuity flags, static cohorts, flags whose targeting depends on person properties you don't pass in).

  • Local (getFeatureFlag, isFeatureEnabled) runs against flag definitions cached on your Convex deployment. Works in queries, mutations, and actions. No per-call network round-trip. Queries re-run reactively when cached definitions refresh.
  • Remote (evaluateFlag, evaluateAllFlags) hits PostHog's /flags endpoint on every call. Action-only. Handles every flag, including the ones local can't.

To enable local eval, add a cron that refreshes flag definitions on whatever interval suits you:

TypeScript
// convex/crons.ts
import { cronJobs } from "convex/server"
import { internalAction } from "./_generated/server"
import { internal } from "./_generated/api"
import { posthog } from "./posthog"
export const refreshPosthogFlags = internalAction({
args: {},
handler: async (ctx) => {
await posthog.refreshFlagDefinitions(ctx)
},
})
const crons = cronJobs()
crons.interval(
"refresh posthog feature flag definitions",
{ minutes: 1 },
internal.crons.refreshPosthogFlags
)
export default crons

Then read a flag from a query:

TypeScript
// convex/pricing.ts
import { query } from "./_generated/server"
import { v } from "convex/values"
import { posthog } from "./posthog"
export const getDiscount = query({
args: { userId: v.string() },
handler: async (ctx, args) => {
const variant = await posthog.getFeatureFlag(ctx, {
key: "discount-campaign",
distinctId: args.userId,
})
if (variant === "variant-a") return { discount: 20 }
if (variant === "variant-b") return { discount: 10 }
return { discount: 0 }
},
})

Because this is a regular Convex query, the React client that subscribes to it will re-run and re-render automatically the next time the cron refreshes flag definitions (up to your configured interval, one minute in the example above).

If you're running an experiment against a locally-evaluated flag, fire an exposure event yourself from a mutation or action, since local eval can't schedule capture from inside a query:

TypeScript
await posthog.capture(ctx, {
event: "$feature_flag_called",
distinctId: userId,
properties: {
$feature_flag: "discount-campaign",
$feature_flag_response: variant,
locally_evaluated: true,
},
})
When local eval returns null

A handful of flag types can't be resolved locally (experience continuity flags, static cohorts, flags whose targeting depends on person properties you don't pass in). For those, getFeatureFlag returns null. Call evaluateFlag from an action to hit /flags directly. See the full list of limitations.

6. Capture exceptions with custom properties

If you want every uncaught exception forwarded to PostHog automatically without wrapping each call site, configure Convex's first-party PostHog Error Tracking destination in the Convex dashboard. Use captureException when you want to attach custom properties at a specific call site:

TypeScript
// convex/billing.ts
import { action } from "./_generated/server"
import { v } from "convex/values"
import { posthog } from "./posthog"
export const chargeCard = action({
args: { userId: v.string(), amount: v.number() },
handler: async (ctx, args) => {
try {
await callStripe(args.amount)
} catch (error) {
await posthog.captureException(ctx, {
error,
distinctId: args.userId,
additionalProperties: { amount: args.amount },
})
throw error
}
},
})

Use the dashboard destination for catch-all coverage. Use captureException here when you need explicit control over the properties on the exception event.

Stitch frontend and backend events together

Convex apps almost always have a frontend talking to a Convex backend. PostHog stitches both sides onto the same person timeline when you use the same distinctId string on both.

Install posthog-js and @posthog/react in your frontend. The example below is React, but the same identity-stitching technique works with any JavaScript framework PostHog supports. The only requirement is that you call posthog.identify() with the same string the backend uses as distinctId.

npm install posthog-js @posthog/react

Initialize PostHog at the root of your app, the same way you would in any React project:

TSX
// src/main.tsx
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import posthog from "posthog-js"
import { PostHogProvider } from "@posthog/react"
import App from "./App"
posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_TOKEN, {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
})
createRoot(document.getElementById("root")!).render(
<StrictMode>
<PostHogProvider client={posthog}>
<App />
</PostHogProvider>
</StrictMode>
)

Then, right after a user signs in via Convex Auth (or Clerk, Auth0, etc.), call identify on the frontend with the same Convex user ID your backend uses. The currentUser query referenced below is whatever Convex query exposes the authenticated user to your client (the Convex Auth docs cover defining it):

TSX
// src/components/AuthSync.tsx
import { useEffect } from "react"
import { useQuery } from "convex/react"
import { usePostHog } from "@posthog/react"
import { api } from "../../convex/_generated/api"
export function AuthSync() {
const posthog = usePostHog()
const me = useQuery(api.users.currentUser) // your own query
useEffect(() => {
if (me?._id) {
posthog.identify(me._id, { email: me.email, name: me.name })
}
}, [me?._id, posthog])
return null
}

Mount <AuthSync /> once near the root of your app, inside the <PostHogProvider> and below your Convex <Authenticated> boundary if you have one. The me?._id guard skips the identify call until the user is authenticated.

Now an event captured from a mutation with distinctId: userId and a $pageview from the browser with the same user ID land on the same person in PostHog. Session replays, server-side mutations, exceptions, and logs all land on one timeline for that user.

Trace LLM calls and Convex Agent

If your app calls LLM providers from Convex actions, or uses @convex-dev/agent, pipe the traces into PostHog AI observability. You get token counts, latency, and cost per generation, the full request and response transcript for every model call, and groupings by user, model, and agent run.

Setup uses @posthog/ai and OpenTelemetry rather than the @posthog/convex component. The full installation steps live on the Convex AI observability installation page, covering both vanilla Vercel AI SDK or OpenAI calls and experimental_telemetry on @convex-dev/agent.

Sync Convex tables into PostHog

If you want to query Convex data alongside event data, use the Convex data warehouse source to stream your tables into PostHog's warehouse. You can then query them with SQL, join them to event data, and build insights over the union.

Requires a Convex Professional plan. Convex's streaming export API is gated there.

Community questions

Was this page useful?

Questions about this page? or post a community question.