From f046a3df911348a197725f4a42ee1a637670aa9a Mon Sep 17 00:00:00 2001 From: "wizard-ci-bot[bot]" <254716194+wizard-ci-bot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:23:00 +0000 Subject: [PATCH 1/3] wizard-ci: react-router/saas-template (1/3) --- .../react-router/saas-template/.env.example | 4 ++ .../react-router/saas-template/.gitignore | 1 + .../saas-template/app/entry.client.tsx | 43 ++++++++++++++++++- .../saas-template/app/entry.server.tsx | 3 ++ ...l-or-modify-subscription-modal-content.tsx | 12 +++++- .../create-subscription-modal-content.tsx | 12 +++++- .../create-organization-form-card.tsx | 15 ++++++- .../organizations/layout/nav-user.tsx | 9 +++- .../settings/general/danger-zone.tsx | 8 +++- .../general/general-organization-settings.tsx | 9 ++-- .../team-members/invite-by-email-card.tsx | 8 +++- .../team-members/invite-link-card.tsx | 16 ++++++- .../settings/account/account-settings.tsx | 9 ++-- .../settings/account/danger-zone.tsx | 8 +++- .../_authenticated-routes-layout.tsx | 32 +++++++++++++- .../onboarding+/organization.tsx | 15 +++++-- .../onboarding+/user-account.tsx | 15 ++++++- .../$organizationSlug+/pastes.tsx | 8 +++- 18 files changed, 204 insertions(+), 23 deletions(-) diff --git a/apps/basic-integration/react-router/saas-template/.env.example b/apps/basic-integration/react-router/saas-template/.env.example index 9222ce272..90e4c382c 100644 --- a/apps/basic-integration/react-router/saas-template/.env.example +++ b/apps/basic-integration/react-router/saas-template/.env.example @@ -12,6 +12,10 @@ VITE_SUPABASE_URL=https://abcdefghijklmnopqrst.supabase.co VITE_SUPABASE_ANON_KEY="ey_mock-anon-key" SUPABASE_SERVICE_ROLE_KEY="ey_mock-service-role" +# PostHog +VITE_PUBLIC_POSTHOG_PROJECT_TOKEN= +VITE_PUBLIC_POSTHOG_HOST= + # App APP_URL="http://localhost:3000" COOKIE_SECRET=a-sufficiently-long-secret-ideally-32-characters-or-more diff --git a/apps/basic-integration/react-router/saas-template/.gitignore b/apps/basic-integration/react-router/saas-template/.gitignore index bf57516ba..a550c6444 100644 --- a/apps/basic-integration/react-router/saas-template/.gitignore +++ b/apps/basic-integration/react-router/saas-template/.gitignore @@ -28,3 +28,4 @@ stripe-events.txt # Prisma /app/generated/ +.env diff --git a/apps/basic-integration/react-router/saas-template/app/entry.client.tsx b/apps/basic-integration/react-router/saas-template/app/entry.client.tsx index 6508477e9..2b7d3abf4 100644 --- a/apps/basic-integration/react-router/saas-template/app/entry.client.tsx +++ b/apps/basic-integration/react-router/saas-template/app/entry.client.tsx @@ -1,11 +1,44 @@ +import { PostHogErrorBoundary, PostHogProvider } from "@posthog/react"; import i18next from "i18next"; import I18nextBrowserLanguageDetector from "i18next-browser-languagedetector"; import Fetch from "i18next-fetch-backend"; +import posthog from "posthog-js"; import { StrictMode, startTransition } from "react"; import { hydrateRoot } from "react-dom/client"; import { I18nextProvider, initReactI18next } from "react-i18next"; import { HydratedRouter } from "react-router/dom"; +const projectToken = import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN; +const host = import.meta.env.VITE_PUBLIC_POSTHOG_HOST; + +if (!projectToken) { + if (import.meta.env.DEV) { + throw new Error( + "VITE_PUBLIC_POSTHOG_PROJECT_TOKEN variable required by PostHog is missing or un-configured, this causes events to be silently missed. This error stops appearing once VITE_PUBLIC_POSTHOG_PROJECT_TOKEN is configured", + ); + } +} else if (!host) { + if (import.meta.env.DEV) { + throw new Error( + "VITE_PUBLIC_POSTHOG_HOST variable required by PostHog is missing or un-configured, this causes events to be silently missed. This error stops appearing once VITE_PUBLIC_POSTHOG_HOST is configured", + ); + } +} else { + posthog.init(projectToken, { + api_host: host, + capture_exceptions: { + capture_console_errors: false, + capture_unhandled_errors: true, + capture_unhandled_rejections: true, + }, + defaults: "2026-05-30", + logs: { + environment: import.meta.env.MODE, + serviceName: "saas-template-web", + }, + }); +} + async function hydrate() { await i18next .use(initReactI18next) @@ -23,7 +56,15 @@ async function hydrate() { document, - + {projectToken && host ? ( + + + + + + ) : ( + + )} , ); diff --git a/apps/basic-integration/react-router/saas-template/app/entry.server.tsx b/apps/basic-integration/react-router/saas-template/app/entry.server.tsx index d80f41113..878904641 100644 --- a/apps/basic-integration/react-router/saas-template/app/entry.server.tsx +++ b/apps/basic-integration/react-router/saas-template/app/entry.server.tsx @@ -107,6 +107,7 @@ export default async function handleRequest( "connect-src": [ MODE === "development" ? "ws:" : undefined, "'self'", + "https://*.posthog.com", ], "font-src": ["'self'"], "frame-src": ["'self'"], @@ -119,10 +120,12 @@ export default async function handleRequest( "script-src": [ "'strict-dynamic'", "'self'", + "https://*.posthog.com", `'nonce-${nonce}'`, ], // Inline event handlers with nonce "script-src-attr": [`'nonce-${nonce}'`], + "worker-src": ["'self'", "blob:"], }, }, // Report-only in dev/test, enforce in production diff --git a/apps/basic-integration/react-router/saas-template/app/features/billing/cancel-or-modify-subscription-modal-content.tsx b/apps/basic-integration/react-router/saas-template/app/features/billing/cancel-or-modify-subscription-modal-content.tsx index acc7d69c5..6a9510e95 100644 --- a/apps/basic-integration/react-router/saas-template/app/features/billing/cancel-or-modify-subscription-modal-content.tsx +++ b/apps/basic-integration/react-router/saas-template/app/features/billing/cancel-or-modify-subscription-modal-content.tsx @@ -1,3 +1,4 @@ +import { usePostHog } from "@posthog/react"; import { IconCheck } from "@tabler/icons-react"; import type { ComponentProps, MouseEventHandler } from "react"; import { useState } from "react"; @@ -54,6 +55,7 @@ export function CancelOrModifySubscriptionModalContent({ keyPrefix: "billingPage.pricingModal", }); const [billingPeriod, setBillingPeriod] = useState("annual"); + const posthog = usePostHog(); const isSubmitting = isSwitchingToLow || isSwitchingToMid || isSwitchingToHigh; @@ -119,7 +121,15 @@ export function CancelOrModifySubscriptionModalContent({ return ( <> -
+ + posthog?.capture("subscription_change_submitted", { + billing_period: billingPeriod, + }) + } + replace + >
+ + posthog?.capture("subscription_checkout_started", { + billing_period: billingPeriod, + }) + } + replace + > {unavailable.length > 0 && ( {tModal("disabledPlansAlert.title")} diff --git a/apps/basic-integration/react-router/saas-template/app/features/organizations/create-organization/create-organization-form-card.tsx b/apps/basic-integration/react-router/saas-template/app/features/organizations/create-organization/create-organization-form-card.tsx index 5df760c88..1873d3c74 100644 --- a/apps/basic-integration/react-router/saas-template/app/features/organizations/create-organization/create-organization-form-card.tsx +++ b/apps/basic-integration/react-router/saas-template/app/features/organizations/create-organization/create-organization-form-card.tsx @@ -1,6 +1,7 @@ import type { SubmissionResult } from "@conform-to/react/future"; import { useForm } from "@conform-to/react/future"; import { coerceFormValue } from "@conform-to/zod/v4/future"; +import { usePostHog } from "@posthog/react"; import { IconBuilding } from "@tabler/icons-react"; import { Trans, useTranslation } from "react-i18next"; import { Form, href, Link } from "react-router"; @@ -32,6 +33,7 @@ import { } from "~/components/ui/field"; import { Input } from "~/components/ui/input"; import { Spinner } from "~/components/ui/spinner"; +import { posthogLog } from "~/lib/posthog-logger.client"; const ONE_MB = 1_000_000; @@ -45,6 +47,7 @@ export function CreateOrganizationFormCard({ lastResult, }: CreateOrganizationFormCardProps) { const { t } = useTranslation("organizations", { keyPrefix: "new.form" }); + const posthog = usePostHog(); const { form, fields } = useForm( coerceFormValue(createOrganizationFormSchema), { @@ -61,7 +64,17 @@ export function CreateOrganizationFormCard({ - + { + posthog?.capture("organization_created"); + posthogLog.info(posthog, "organization creation submitted", { + workflow: "organization_creation", + }); + }} + >
- + posthog?.reset()} + replace + > @@ -97,7 +99,11 @@ function DeleteOrganizationDialogComponent({ {t("dialogDescription")} - + posthog?.capture("organization_deletion_submitted")} + >
diff --git a/apps/basic-integration/react-router/saas-template/app/features/organizations/settings/general/general-organization-settings.tsx b/apps/basic-integration/react-router/saas-template/app/features/organizations/settings/general/general-organization-settings.tsx index 421a03d2c..acc476a04 100644 --- a/apps/basic-integration/react-router/saas-template/app/features/organizations/settings/general/general-organization-settings.tsx +++ b/apps/basic-integration/react-router/saas-template/app/features/organizations/settings/general/general-organization-settings.tsx @@ -1,6 +1,7 @@ import type { SubmissionResult } from "@conform-to/react/future"; import { useForm } from "@conform-to/react/future"; import { coerceFormValue } from "@conform-to/zod/v4/future"; +import { usePostHog } from "@posthog/react"; import { Trans, useTranslation } from "react-i18next"; import { Form, useNavigation } from "react-router"; @@ -68,6 +69,7 @@ export function GeneralOrganizationSettings({ const { t } = useTranslation("organizations", { keyPrefix: "settings.general", }); + const posthog = usePostHog(); const { form, fields } = useForm( coerceFormValue(updateOrganizationFormSchema), @@ -83,15 +85,16 @@ export function GeneralOrganizationSettings({ return ( 0 ? `${form.descriptionId} ${form.errorId}` : form.descriptionId } aria-invalid={form.errors && form.errors.length > 0 ? true : undefined} + encType="multipart/form-data" + method="POST" + {...form.props} + onSubmit={() => posthog?.capture("organization_settings_updated")} >
diff --git a/apps/basic-integration/react-router/saas-template/app/features/organizations/settings/team-members/invite-by-email-card.tsx b/apps/basic-integration/react-router/saas-template/app/features/organizations/settings/team-members/invite-by-email-card.tsx index 38e582460..6002963e2 100644 --- a/apps/basic-integration/react-router/saas-template/app/features/organizations/settings/team-members/invite-by-email-card.tsx +++ b/apps/basic-integration/react-router/saas-template/app/features/organizations/settings/team-members/invite-by-email-card.tsx @@ -1,5 +1,6 @@ import type { SubmissionResult } from "@conform-to/react/future"; import { useForm } from "@conform-to/react/future"; +import { usePostHog } from "@posthog/react"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Form } from "react-router"; @@ -46,6 +47,7 @@ export function EmailInviteCard({ const { t } = useTranslation("organizations", { keyPrefix: "settings.teamMembers.inviteByEmail", }); + const posthog = usePostHog(); const { form, fields, intent } = useForm(inviteByEmailSchema, { lastResult, @@ -70,7 +72,11 @@ export function EmailInviteCard({ - + posthog?.capture("team_member_invite_submitted")} + >
diff --git a/apps/basic-integration/react-router/saas-template/app/features/organizations/settings/team-members/invite-link-card.tsx b/apps/basic-integration/react-router/saas-template/app/features/organizations/settings/team-members/invite-link-card.tsx index b3a5c9225..60f4d24f1 100644 --- a/apps/basic-integration/react-router/saas-template/app/features/organizations/settings/team-members/invite-link-card.tsx +++ b/apps/basic-integration/react-router/saas-template/app/features/organizations/settings/team-members/invite-link-card.tsx @@ -1,3 +1,4 @@ +import { usePostHog } from "@posthog/react"; import { IconAlertTriangle, IconClipboardCheck, @@ -37,6 +38,7 @@ export function InviteLinkCard({ const { t, i18n } = useTranslation("organizations", { keyPrefix: "settings.teamMembers.inviteLink", }); + const posthog = usePostHog(); const [linkCopied, setLinkCopied] = useState(false); @@ -160,7 +162,12 @@ export function InviteLinkCard({
- + posthog?.capture("invite_link_created")} + replace + > + } + ``` + + You should see these events in a minute or two in the [activity tab](https://app.posthog.com/activity/explore). + +5. 4 + + ## Access PostHog methods + + Required + + On the client-side, you can access the PostHog client using the `usePostHog` hook. This hook returns the initialized PostHog client, which you can use to call PostHog methods. For example: + + TSX + + ```jsx + import { usePostHog } from '@posthog/react' + + function App() { + const posthog = usePostHog() + return + } + ``` + + For a complete list of available methods, see the [posthog-js documentation](/docs/libraries/js.md). + +6. 5 + + ## Identify your user + + Recommended + + Now that you can capture basic client-side events, you'll want to identify your user so you can associate users with captured events. + + Generally, you identify users when they log in or when they input some identifiable information (e.g. email, name, etc.). You can identify users by calling the `identify` method on the PostHog client: + + TSX + + ```jsx + export default function Login() { + const { user, login } = useAuth(); + const posthog = usePostHog(); + + const handleLogin = async (e: React.FormEvent) => { + // existing code to handle login... + const user = await login({ email, password }); + + posthog?.identify(user.email, + { + email: user.email, + name: user.name, + } + ); + posthog?.capture('user_logged_in'); + }; + + return ( +
+ {/* ... existing code ... */} + +
+ ); + } + ``` + + PostHog automatically generates anonymous IDs for users before they're identified. When you call identify, a new identified person is created. All previous events tracked with the anonymous ID link to the new identified distinct ID, and all future captures on the same browser associate with the identified person. + +7. 6 + + ## Create an error boundary + + Recommended + + PostHog can capture exceptions thrown in your app through an error boundary. PostHog provides a `PostHogErrorBoundary` component that you can use to capture exceptions. You can wrap your app with this component to capture exceptions. + + TSX + + ```jsx + ReactDOM.createRoot(root).render( + + + + + + }> + {/* ... Your routes ... */} + + + + + + , + ); + ``` + + This automatically captures exceptions thrown in your React Router app using the `posthog.captureException()` method. + +8. 7 + + ## Tracking element visibility + + Recommended + + The `PostHogCaptureOnViewed` component enables you to automatically capture events when elements scroll into view in the browser. This is useful for tracking impressions of important content, monitoring user engagement with specific sections, or understanding which parts of your page users are actually seeing. + + The component wraps your content and sends a `$element_viewed` event to PostHog when the wrapped element becomes visible in the viewport. It only fires once per component instance. + + **Basic usage:** + + React + + ```jsx + import { PostHogCaptureOnViewed } from '@posthog/react' + + function App() { + return ( + +
Your important content here
+
+ ) + } + ``` + + **With custom properties:** + + You can include additional properties with the event to provide more context: + + React + + ```jsx + + + + ``` + + **Tracking multiple children:** + + Use `trackAllChildren` to track each child element separately. This is useful for galleries or lists where you want to know which specific items were viewed: + + React + + ```jsx + + + + + + ``` + + When `trackAllChildren` is enabled, each child element sends its own event with a `child_index` property indicating its position. + + **Custom intersection observer options:** + + You can customize when elements are considered "viewed" by passing options to the `IntersectionObserver`: + + React + + ```jsx + +