diff --git a/.env.example b/.env.example
index 490b0f0..f973d3a 100644
--- a/.env.example
+++ b/.env.example
@@ -10,3 +10,8 @@ N8N_AI_SEARCH_WEBHOOK="https://n8n.mzaxd.fun/webhook/ai-search"
# Internationalization
NEXT_INTL_DEFAULT_LOCALE="zh"
NEXT_INTL_SUPPORTED_LOCALES="zh,en"
+
+# Optional self-hosted analytics
+NEXT_PUBLIC_UMAMI_HOST_URL="https://analytics.example.com"
+NEXT_PUBLIC_UMAMI_WEBSITE_ID="your-umami-website-id"
+NEXT_PUBLIC_UMAMI_DOMAINS="example.com,www.example.com"
diff --git a/package.json b/package.json
index f832e9b..d9347ba 100644
--- a/package.json
+++ b/package.json
@@ -14,8 +14,6 @@
},
"dependencies": {
"@prisma/client": "^6.1.0",
- "@vercel/analytics": "^1.6.1",
- "@vercel/speed-insights": "^1.3.1",
"lucide-react": "^0.468.0",
"next": "15.1.11",
"next-intl": "^4.0.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 88e67e4..5647966 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,12 +11,6 @@ importers:
'@prisma/client':
specifier: ^6.1.0
version: 6.19.1(prisma@6.19.1(typescript@5.9.3))(typescript@5.9.3)
- '@vercel/analytics':
- specifier: ^1.6.1
- version: 1.6.1(next@15.1.11(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
- '@vercel/speed-insights':
- specifier: ^1.3.1
- version: 1.3.1(next@15.1.11(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@19.2.3)
@@ -1044,55 +1038,6 @@ packages:
cpu: [x64]
os: [win32]
- '@vercel/analytics@1.6.1':
- resolution: {integrity: sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==}
- peerDependencies:
- '@remix-run/react': ^2
- '@sveltejs/kit': ^1 || ^2
- next: '>= 13'
- react: ^18 || ^19 || ^19.0.0-rc
- svelte: '>= 4'
- vue: ^3
- vue-router: ^4
- peerDependenciesMeta:
- '@remix-run/react':
- optional: true
- '@sveltejs/kit':
- optional: true
- next:
- optional: true
- react:
- optional: true
- svelte:
- optional: true
- vue:
- optional: true
- vue-router:
- optional: true
-
- '@vercel/speed-insights@1.3.1':
- resolution: {integrity: sha512-PbEr7FrMkUrGYvlcLHGkXdCkxnylCWePx7lPxxq36DNdfo9mcUjLOmqOyPDHAOgnfqgGGdmE3XI9L/4+5fr+vQ==}
- peerDependencies:
- '@sveltejs/kit': ^1 || ^2
- next: '>= 13'
- react: ^18 || ^19 || ^19.0.0-rc
- svelte: '>= 4'
- vue: ^3
- vue-router: ^4
- peerDependenciesMeta:
- '@sveltejs/kit':
- optional: true
- next:
- optional: true
- react:
- optional: true
- svelte:
- optional: true
- vue:
- optional: true
- vue-router:
- optional: true
-
'@vitest/expect@2.1.9':
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
@@ -3797,16 +3742,6 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
- '@vercel/analytics@1.6.1(next@15.1.11(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)':
- optionalDependencies:
- next: 15.1.11(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
- react: 19.2.3
-
- '@vercel/speed-insights@1.3.1(next@15.1.11(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)':
- optionalDependencies:
- next: 15.1.11(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
- react: 19.2.3
-
'@vitest/expect@2.1.9':
dependencies:
'@vitest/spy': 2.1.9
diff --git a/src/app/UmamiMetrics.tsx b/src/app/UmamiMetrics.tsx
new file mode 100644
index 0000000..d789346
--- /dev/null
+++ b/src/app/UmamiMetrics.tsx
@@ -0,0 +1,50 @@
+import Script from "next/script";
+
+const beforeSendScript = `
+window.umamiBeforeSend = function (_type, payload) {
+ try {
+ var rawUrl = payload && typeof payload.url === "string" ? payload.url : window.location.href;
+ var pathname = new URL(rawUrl, window.location.origin).pathname;
+ var isTrackable =
+ pathname === "/zh" ||
+ pathname === "/en" ||
+ pathname.startsWith("/zh/") ||
+ pathname.startsWith("/en/");
+
+ return isTrackable ? payload : false;
+ } catch (_error) {
+ return false;
+ }
+};
+`;
+
+export function UmamiMetrics() {
+ const hostUrl = process.env.NEXT_PUBLIC_UMAMI_HOST_URL?.replace(/\/+$/, "");
+ const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
+ const domains = process.env.NEXT_PUBLIC_UMAMI_DOMAINS;
+
+ if (!hostUrl || !websiteId) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/app/VercelMetrics.tsx b/src/app/VercelMetrics.tsx
deleted file mode 100644
index c3e68e9..0000000
--- a/src/app/VercelMetrics.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-"use client"
-
-import { Analytics } from "@vercel/analytics/next"
-import { SpeedInsights } from "@vercel/speed-insights/next"
-
-const isTrackablePath = (url: string): boolean => {
- try {
- const pathname = new URL(url).pathname
-
- // Only keep user-facing locale pages to control usage on the Hobby plan.
- return pathname === "/zh" || pathname === "/en" || pathname.startsWith("/zh/") || pathname.startsWith("/en/")
- } catch {
- return false
- }
-}
-
-export function VercelMetrics() {
- return (
- <>
- (isTrackablePath(event.url) ? event : null)} />
- (isTrackablePath(data.url) ? data : null)} />
- >
- )
-}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index d8d00dd..02e7275 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,5 +1,5 @@
import type { Metadata } from "next"
-import { VercelMetrics } from "./VercelMetrics"
+import { UmamiMetrics } from "./UmamiMetrics"
import "./globals.css"
export const metadata: Metadata = {
@@ -7,8 +7,6 @@ export const metadata: Metadata = {
description: "发现和探索全网优质 AI 项目",
}
-const isVercelProduction = process.env.VERCEL_ENV === "production"
-
export default function RootLayout({
children,
}: Readonly<{
@@ -18,7 +16,7 @@ export default function RootLayout({
{children}
- {isVercelProduction ? : null}
+
)