diff --git a/AGENTS.md b/AGENTS.md
index 37de005..c9b0ab5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -74,6 +74,12 @@
read-only `spoon-ci` machine identity (Gitea secrets `INFISICAL_CLIENT_ID` and
`INFISICAL_CLIENT_SECRET`), and hands the result to the scripts as
`CI_ENV_FILE`. `scripts/export-env` refuses to run in CI.
+- Plausible (`NEXT_PUBLIC_PLAUSIBLE_SRC`, the per-site script) loads through a
+ same-origin `/pa` proxy and PostHog (`NEXT_PUBLIC_POSTHOG_KEY`/`_HOST`) through
+ `/ingest`, after the page is idle. Both stay off when unset. Session replay comes
+ from PostHog; Sentry carries errors and traces only. Client code reads public env
+ from `@/publicEnv`, and `packages/ui/src/index.tsx` holds re-exports only so
+ `optimizePackageImports` can load just the modules a page imports.
- A push to `main` builds the images, syncs the Convex env, deploys Convex, pushes
the images, and asks the VPS to redeploy `podman-spoon.service`
(`ssh gib@192.168.2.2 spoon`). A push to `main` is a production deploy.
diff --git a/README.md b/README.md
index 1f10691..3816660 100644
--- a/README.md
+++ b/README.md
@@ -549,7 +549,9 @@ not call Infisical.
| `NEXT_PUBLIC_CONVEX_URL` | Convex client URL |
| `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL` | Browser-facing worker WS base for the terminal (**build-time** var) |
| `NEXT_PUBLIC_DEPLOYMENT_URL` | Convex dashboard/deployment URL when needed |
-| `NEXT_PUBLIC_PLAUSIBLE_URL` | Plausible analytics endpoint |
+| `NEXT_PUBLIC_PLAUSIBLE_SRC` | Plausible per-site script URL (optional) |
+| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog project key (optional) |
+| `NEXT_PUBLIC_POSTHOG_HOST` | PostHog host (optional) |
| `NEXT_PUBLIC_SENTRY_DSN` | Browser Sentry DSN |
| `NEXT_PUBLIC_SENTRY_URL` | Sentry instance URL |
| `NEXT_PUBLIC_SENTRY_ORG` | Sentry organization |
diff --git a/apps/next/next.config.js b/apps/next/next.config.js
index 4d9bb7a..fe17f3c 100644
--- a/apps/next/next.config.js
+++ b/apps/next/next.config.js
@@ -3,12 +3,16 @@ import { createJiti } from 'jiti';
import { withPlausibleProxy } from 'next-plausible';
const jiti = createJiti(import.meta.url);
-await jiti.import('./src/env');
+const { env } = /** @type {typeof import('./src/env')} */ (
+ await jiti.import('./src/env')
+);
+const { posthogScriptAliases } =
+ /** @type {typeof import('./src/lib/posthog-scripts')} */ (
+ await jiti.import('./src/lib/posthog-scripts')
+ );
/** @type {import("next").NextConfig} */
-const config = withPlausibleProxy({
- customDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
-})({
+const baseConfig = {
output: 'standalone',
images: {
remotePatterns: [
@@ -23,11 +27,54 @@ const config = withPlausibleProxy({
serverActions: {
bodySizeLimit: '10mb',
},
+ // Resolves `import { Button } from '@spoon/ui'` to the button module alone,
+ // so marketing pages never pull in admin-only Radix widgets or Recharts.
+ // radix-ui is the umbrella package the shadcn components import from.
+ // lucide-react, date-fns and @tabler/icons-react are on Next's default list.
+ optimizePackageImports: ['@spoon/ui', 'radix-ui'],
},
/** Enables hot reloading for local packages without a build step */
transpilePackages: ['@spoon/backend', '@spoon/ui'],
typescript: { ignoreBuildErrors: true },
-});
+ // PostHog's capture paths end in a slash (/e/, /i/v0/e/), and Next's
+ // built-in trailing-slash redirect would answer those proxied POSTs with a
+ // redirect. It is turned off here and replaced by the redirect below, which
+ // strips trailing slashes everywhere except /ingest.
+ skipTrailingSlashRedirect: true,
+ redirects: async () => [
+ {
+ source: '/:path((?!ingest(?:/|$)).+)/',
+ destination: '/:path',
+ permanent: true,
+ },
+ ],
+ // Same-origin proxy for PostHog, so blocklists that match the PostHog host
+ // never see the request. The aliased extension scripts (blocked by file
+ // name, see src/lib/posthog-scripts.ts) map back to their real files first.
+ rewrites: async () => {
+ const host = env.NEXT_PUBLIC_POSTHOG_HOST;
+ if (!host) return [];
+ return [
+ ...Object.entries(posthogScriptAliases).map(([name, alias]) => ({
+ source: `/ingest/static/${alias}.js`,
+ destination: `${host}/static/${name}.js`,
+ })),
+ { source: '/ingest/:path*', destination: `${host}/:path*` },
+ ];
+ },
+};
+
+// Serves Plausible's per-site script from this origin so ad blockers see a
+// first-party request. next-plausible 4 takes the full script URL. Its default
+// event path is /api/event, which sits inside Payload's /api routes, so both
+// paths move under /pa.
+const config = env.NEXT_PUBLIC_PLAUSIBLE_SRC
+ ? withPlausibleProxy({
+ src: env.NEXT_PUBLIC_PLAUSIBLE_SRC,
+ scriptPath: '/pa/script.js',
+ apiPath: '/pa/event',
+ })(baseConfig)
+ : baseConfig;
const sentryConfig = {
// For all available options, see:
diff --git a/apps/next/package.json b/apps/next/package.json
index c54c446..db40d1f 100644
--- a/apps/next/package.json
+++ b/apps/next/package.json
@@ -34,8 +34,9 @@
"monaco-editor": "~0.55.1",
"monaco-vim": "~0.4.4",
"next": "^16.2.1",
- "next-plausible": "^3.12.5",
+ "next-plausible": "^4.0.0",
"next-themes": "^0.4.6",
+ "posthog-js": "^1.435.6",
"react": "catalog:react19",
"react-dom": "catalog:react19",
"require-in-the-middle": "^7.5.2",
diff --git a/apps/next/src/app/global-error.tsx b/apps/next/src/app/global-error.tsx
index 0570801..6515e22 100644
--- a/apps/next/src/app/global-error.tsx
+++ b/apps/next/src/app/global-error.tsx
@@ -7,8 +7,8 @@ import { Geist, Geist_Mono } from 'next/font/google';
import '@/app/styles.css';
import { useEffect } from 'react';
-import { env } from '@/env';
import { generateMetadata } from '@/lib/metadata';
+import { publicEnv } from '@/publicEnv';
import * as Sentry from '@sentry/nextjs';
import PlausibleProvider from 'next-plausible';
@@ -41,32 +41,34 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
- return (
-
-
-
-
-
-
- {reset !== undefined && (
-
- )}
-
-
-
-
-
-
+
+
+ {reset !== undefined && (
+
+ )}
+
+
+
+ );
+ return (
+
+
+ {publicEnv.NEXT_PUBLIC_PLAUSIBLE_SRC ? (
+ {page}
+ ) : (
+ page
+ )}
+
+
);
};
export default GlobalError;
diff --git a/apps/next/src/app/layout.tsx b/apps/next/src/app/layout.tsx
index 4afb928..71d55b3 100644
--- a/apps/next/src/app/layout.tsx
+++ b/apps/next/src/app/layout.tsx
@@ -43,34 +43,38 @@ const RootLayout = ({
}: Readonly<{
children: React.ReactNode;
}>) => {
+ const app = (
+
+
+
+
+ {children}
+
+
+
+
+
+ );
return (
-
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-
-
+
+
+ {/* Plausible loads its per-site script through the same-origin
+ proxy in next.config.js; without that config it stays off. */}
+ {env.NEXT_PUBLIC_PLAUSIBLE_SRC ? (
+ {app}
+ ) : (
+ app
+ )}
+
+
);
};
diff --git a/apps/next/src/components/layout/auth/profile/sign-out.tsx b/apps/next/src/components/layout/auth/profile/sign-out.tsx
index 62ee9d0..7fc94b1 100644
--- a/apps/next/src/components/layout/auth/profile/sign-out.tsx
+++ b/apps/next/src/components/layout/auth/profile/sign-out.tsx
@@ -2,6 +2,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
+import { resetAnalyticsIdentity } from '@/lib/analytics';
import { useAuthActions } from '@convex-dev/auth/react';
import { LogOut } from 'lucide-react';
@@ -22,6 +23,7 @@ export const SignOutForm = () => {
setIsSigningOut(true);
try {
await signOut();
+ resetAnalyticsIdentity();
router.push('/');
} catch (error) {
console.error('Sign out error:', error);
diff --git a/apps/next/src/components/layout/header/controls/AvatarDropdown.tsx b/apps/next/src/components/layout/header/controls/AvatarDropdown.tsx
index 21c598e..9e9da9e 100644
--- a/apps/next/src/components/layout/header/controls/AvatarDropdown.tsx
+++ b/apps/next/src/components/layout/header/controls/AvatarDropdown.tsx
@@ -2,6 +2,7 @@
import Link from 'next/link';
import { useRouter } from 'next/navigation';
+import { resetAnalyticsIdentity, useAnalyticsIdentity } from '@/lib/analytics';
import { isRemoteImageUrl } from '@/lib/avatar';
import { useAuthActions } from '@convex-dev/auth/react';
import { useConvexAuth, useQuery } from 'convex/react';
@@ -23,6 +24,7 @@ export const AvatarDropdown = () => {
const { isLoading, isAuthenticated } = useConvexAuth();
const { signOut } = useAuthActions();
const user = useQuery(api.auth.getUser, {});
+ useAnalyticsIdentity(user?._id);
const remoteImageUrl = isRemoteImageUrl(user?.image) ? user?.image : null;
const currentImageUrl = useQuery(
api.files.getImageUrl,
@@ -83,6 +85,7 @@ export const AvatarDropdown = () => {