diff --git a/apps/public/src/app/(home)/page.tsx b/apps/public/src/app/(home)/page.tsx
index 65f1aba6..9fc5149b 100644
--- a/apps/public/src/app/(home)/page.tsx
+++ b/apps/public/src/app/(home)/page.tsx
@@ -1,3 +1,8 @@
+import {
+ OPENPANEL_BASE_URL,
+ OPENPANEL_DESCRIPTION,
+ OPENPANEL_NAME,
+} from '@/lib/openpanel-brand';
import { AnalyticsInsights } from './_sections/analytics-insights';
import { Collaboration } from './_sections/collaboration';
import { CtaBanner } from './_sections/cta-banner';
@@ -9,9 +14,34 @@ import { Sdks } from './_sections/sdks';
import { Testimonials } from './_sections/testimonials';
import { WhyOpenPanel } from './_sections/why-openpanel';
+const jsonLd = {
+ '@context': 'https://schema.org',
+ '@graph': [
+ {
+ '@type': 'Organization',
+ name: OPENPANEL_NAME,
+ url: OPENPANEL_BASE_URL,
+ sameAs: ['https://github.com/Openpanel-dev/openpanel'],
+ description: OPENPANEL_DESCRIPTION,
+ },
+ {
+ '@type': 'SoftwareApplication',
+ name: OPENPANEL_NAME,
+ applicationCategory: 'AnalyticsApplication',
+ operatingSystem: 'Web',
+ url: OPENPANEL_BASE_URL,
+ description: OPENPANEL_DESCRIPTION,
+ },
+ ],
+};
+
export default function HomePage() {
return (
<>
+
diff --git a/apps/public/src/app/llms-full.txt/route.ts b/apps/public/src/app/llms-full.txt/route.ts
index d494d2cb..53ba1216 100644
--- a/apps/public/src/app/llms-full.txt/route.ts
+++ b/apps/public/src/app/llms-full.txt/route.ts
@@ -1,10 +1,27 @@
+import {
+ OPENPANEL_BASE_URL,
+ OPENPANEL_DESCRIPTION,
+ OPENPANEL_NAME,
+} from '@/lib/openpanel-brand';
import { getLLMText, source } from '@/lib/source';
-export const revalidate = false;
+export const dynamic = 'force-static';
+
+const header = `# ${OPENPANEL_NAME} – Full documentation for LLMs
+
+${OPENPANEL_DESCRIPTION}
+
+This file contains the full text of all documentation pages. Each section is separated by --- and includes a canonical URL.
+
+`;
export async function GET() {
- const scan = source.getPages().map(getLLMText);
- const scanned = await Promise.all(scan);
+ const pages = source.getPages().slice().sort((a, b) => a.url.localeCompare(b.url));
+ const scanned = await Promise.all(pages.map(getLLMText));
- return new Response(scanned.join('\n\n'));
+ return new Response(header + scanned.join('\n\n'), {
+ headers: {
+ 'Content-Type': 'text/plain; charset=utf-8',
+ },
+ });
}
diff --git a/apps/public/src/app/llms.txt/route.ts b/apps/public/src/app/llms.txt/route.ts
new file mode 100644
index 00000000..cc2b3f31
--- /dev/null
+++ b/apps/public/src/app/llms.txt/route.ts
@@ -0,0 +1,80 @@
+import {
+ OPENPANEL_BASE_URL,
+ OPENPANEL_DESCRIPTION,
+ OPENPANEL_NAME,
+} from '@/lib/openpanel-brand';
+
+export const dynamic = 'force-static';
+
+const body = `# ${OPENPANEL_NAME}
+
+> ${OPENPANEL_DESCRIPTION}
+
+## Main pages
+- [Home](${OPENPANEL_BASE_URL}/)
+- [Features](${OPENPANEL_BASE_URL}/features) (event tracking, funnels, retention, web analytics, and more)
+- [Guides](${OPENPANEL_BASE_URL}/guides)
+- [Articles](${OPENPANEL_BASE_URL}/articles)
+- [Open source](${OPENPANEL_BASE_URL}/open-source)
+- [Supporter](${OPENPANEL_BASE_URL}/supporter)
+- [About](${OPENPANEL_BASE_URL}/about)
+- [Contact](${OPENPANEL_BASE_URL}/contact)
+
+## Core docs
+- [What is OpenPanel?](${OPENPANEL_BASE_URL}/docs)
+- [Install OpenPanel](${OPENPANEL_BASE_URL}/docs/get-started/install-openpanel)
+- [Track Events](${OPENPANEL_BASE_URL}/docs/get-started/track-events)
+- [Identify Users](${OPENPANEL_BASE_URL}/docs/get-started/identify-users)
+
+## SDKs
+- [SDKs Overview](${OPENPANEL_BASE_URL}/docs/sdks)
+- [JavaScript](${OPENPANEL_BASE_URL}/docs/sdks/javascript)
+- [React](${OPENPANEL_BASE_URL}/docs/sdks/react)
+- [Next.js](${OPENPANEL_BASE_URL}/docs/sdks/nextjs)
+- [Vue](${OPENPANEL_BASE_URL}/docs/sdks/vue)
+- [React Native](${OPENPANEL_BASE_URL}/docs/sdks/react-native)
+- [Swift](${OPENPANEL_BASE_URL}/docs/sdks/swift)
+- [Kotlin](${OPENPANEL_BASE_URL}/docs/sdks/kotlin)
+- [Python](${OPENPANEL_BASE_URL}/docs/sdks/python)
+
+## API
+- [Authentication](${OPENPANEL_BASE_URL}/docs/api/authentication)
+- [Track API](${OPENPANEL_BASE_URL}/docs/api/track)
+- [Export API](${OPENPANEL_BASE_URL}/docs/api/export)
+- [Insights API](${OPENPANEL_BASE_URL}/docs/api/insights)
+
+## Self-hosting
+- [Self-hosting Guide](${OPENPANEL_BASE_URL}/docs/self-hosting/self-hosting)
+- [Docker Compose](${OPENPANEL_BASE_URL}/docs/self-hosting/deploy-docker-compose)
+- [Environment Variables](${OPENPANEL_BASE_URL}/docs/self-hosting/environment-variables)
+
+## Pricing
+- [Pricing](${OPENPANEL_BASE_URL}/pricing)
+
+## Compare (alternatives)
+- [Mixpanel alternative](${OPENPANEL_BASE_URL}/compare/mixpanel-alternative)
+- [PostHog alternative](${OPENPANEL_BASE_URL}/compare/posthog-alternative)
+- [Google Analytics alternative](${OPENPANEL_BASE_URL}/compare/google-analytics-alternative)
+- [Amplitude alternative](${OPENPANEL_BASE_URL}/compare/amplitude-alternative)
+- [Plausible alternative](${OPENPANEL_BASE_URL}/compare/plausible-alternative)
+- [Umami alternative](${OPENPANEL_BASE_URL}/compare/umami-alternative)
+- [Compare all](${OPENPANEL_BASE_URL}/compare)
+
+## Trust & legal
+- [Privacy Policy](${OPENPANEL_BASE_URL}/privacy)
+- [Terms of Service](${OPENPANEL_BASE_URL}/terms)
+
+## Source
+- [GitHub](https://github.com/Openpanel-dev/openpanel)
+
+## Optional
+- [Full docs for LLMs](${OPENPANEL_BASE_URL}/llms-full.txt)
+`;
+
+export async function GET() {
+ return new Response(body, {
+ headers: {
+ 'Content-Type': 'text/plain; charset=utf-8',
+ },
+ });
+}
diff --git a/apps/public/src/app/md/route.ts b/apps/public/src/app/md/route.ts
new file mode 100644
index 00000000..e65b5b1a
--- /dev/null
+++ b/apps/public/src/app/md/route.ts
@@ -0,0 +1,151 @@
+import { OPENPANEL_BASE_URL } from '@/lib/openpanel-brand';
+import { articleSource, guideSource, pageSource, source } from '@/lib/source';
+import { NextResponse } from 'next/server';
+
+const ALLOWED_PAGE_PATHS = new Set([
+ 'privacy',
+ 'terms',
+ 'about',
+ 'contact',
+ 'cookies',
+]);
+
+export const runtime = 'nodejs';
+
+function stubMarkdown(canonicalUrl: string, path: string): string {
+ return `# ${path}\n\nThis page is available at: [${canonicalUrl}](${canonicalUrl})\n`;
+}
+
+async function getProcessedText(page: {
+ data: { getText?: (type: 'processed' | 'raw') => Promise };
+}): Promise {
+ try {
+ const getText = page.data.getText;
+ if (typeof getText === 'function') {
+ return await getText('processed');
+ }
+ } catch {
+ // ignore
+ }
+ return null;
+}
+
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const pathname = url.pathname;
+
+ // Rewrites preserve the original request URL, so pathname is e.g. /docs/foo.md
+ // Derive path from pathname when present; otherwise use query (e.g. /md?path=...)
+ const pathParam = pathname.endsWith('.md')
+ ? pathname.slice(0, -3)
+ : url.searchParams.get('path');
+
+ if (!pathParam || pathParam.includes('..')) {
+ return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
+ }
+
+ const path = pathParam.startsWith('/') ? pathParam : `/${pathParam}`;
+
+ if (path.startsWith('/docs')) {
+ const slug = path
+ .replace(/^\/docs\/?/, '')
+ .split('/')
+ .filter(Boolean);
+ const page = source.getPage(slug);
+ if (!page) {
+ return new NextResponse('Not found', { status: 404 });
+ }
+ const processed = await page.data.getText('processed');
+ const canonical = `${OPENPANEL_BASE_URL}${page.url}`;
+ const body = `# ${page.data.title}\n\nURL: ${canonical}\n\n${processed}`;
+ return new Response(body, {
+ headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
+ });
+ }
+
+ if (path.startsWith('/articles')) {
+ const slug = path
+ .replace(/^\/articles\/?/, '')
+ .split('/')
+ .filter(Boolean);
+ if (slug.length === 0)
+ return new NextResponse('Not found', { status: 404 });
+ const page = articleSource.getPage(slug);
+ if (!page) {
+ return new NextResponse('Not found', { status: 404 });
+ }
+ const text = await getProcessedText(page);
+ const canonical = `${OPENPANEL_BASE_URL}${page.url}`;
+ const body = text
+ ? `# ${page.data.title}\n\nURL: ${canonical}\n\n${text}`
+ : stubMarkdown(canonical, path);
+ return new Response(body, {
+ headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
+ });
+ }
+
+ if (path.startsWith('/guides')) {
+ const slug = path
+ .replace(/^\/guides\/?/, '')
+ .split('/')
+ .filter(Boolean);
+ if (slug.length === 0)
+ return new NextResponse('Not found', { status: 404 });
+ const page = guideSource.getPage(slug);
+ if (!page) {
+ return new NextResponse('Not found', { status: 404 });
+ }
+ const text = await getProcessedText(page);
+ const canonical = `${OPENPANEL_BASE_URL}${page.url}`;
+ const body = text
+ ? `# ${page.data.title}\n\nURL: ${canonical}\n\n${text}`
+ : stubMarkdown(canonical, path);
+ return new Response(body, {
+ headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
+ });
+ }
+
+ if (
+ path === '/' ||
+ (path.startsWith('/') && path.split('/').filter(Boolean).length === 1)
+ ) {
+ const segment = path.replace(/^\//, '');
+ const slug = segment ? [segment] : [];
+ const page = slug.length ? pageSource.getPage(slug) : null;
+ if (page) {
+ try {
+ const getText = (
+ page.data as { getText?: (mode: string) => Promise }
+ ).getText;
+ if (typeof getText === 'function') {
+ const processed = await getText('processed');
+ const canonical = `${OPENPANEL_BASE_URL}${page.url}`;
+ const body = `# ${page.data.title}\n\nURL: ${canonical}\n\n${processed}`;
+ return new Response(body, {
+ headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
+ });
+ }
+ } catch {
+ // fall through to stub if getText not available
+ }
+ if (ALLOWED_PAGE_PATHS.has(segment)) {
+ return new Response(
+ stubMarkdown(`${OPENPANEL_BASE_URL}/${segment}`, path),
+ {
+ headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
+ },
+ );
+ }
+ }
+ if (ALLOWED_PAGE_PATHS.has(segment)) {
+ return new Response(
+ stubMarkdown(`${OPENPANEL_BASE_URL}/${segment}`, path),
+ {
+ headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
+ },
+ );
+ }
+ }
+
+ return new NextResponse('Not found', { status: 404 });
+}
diff --git a/apps/public/src/app/robots.txt b/apps/public/src/app/robots.txt
index d07d5cd4..36f0f51e 100644
--- a/apps/public/src/app/robots.txt
+++ b/apps/public/src/app/robots.txt
@@ -1,4 +1,8 @@
User-Agent: *
Allow: /
Allow: /og*
+Allow: /llms.txt
+Allow: /llms-full.txt
+Allow: /*.md
+
Sitemap: https://openpanel.dev/sitemap.xml
diff --git a/apps/public/src/app/sitemap.ts b/apps/public/src/app/sitemap.ts
index d5c49ee4..1de8ca41 100644
--- a/apps/public/src/app/sitemap.ts
+++ b/apps/public/src/app/sitemap.ts
@@ -57,6 +57,18 @@ export default async function sitemap(): Promise {
changeFrequency: 'monthly',
priority: 0.7,
},
+ {
+ url: url('/llms.txt'),
+ lastModified: new Date(),
+ changeFrequency: 'monthly',
+ priority: 0.3,
+ },
+ {
+ url: url('/llms-full.txt'),
+ lastModified: new Date(),
+ changeFrequency: 'monthly',
+ priority: 0.3,
+ },
...articles.map((item) => ({
url: url(item.url),
lastModified: item.data.date,
diff --git a/apps/public/src/lib/layout.shared.tsx b/apps/public/src/lib/layout.shared.tsx
index ef2d3cd7..0840ed38 100644
--- a/apps/public/src/lib/layout.shared.tsx
+++ b/apps/public/src/lib/layout.shared.tsx
@@ -1,9 +1,10 @@
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
+import { OPENPANEL_BASE_URL, OPENPANEL_NAME } from './openpanel-brand';
-export const siteName = 'OpenPanel';
+export const siteName = OPENPANEL_NAME;
export const baseUrl =
process.env.NODE_ENV === 'production'
- ? 'https://openpanel.dev'
+ ? OPENPANEL_BASE_URL
: 'http://localhost:3000';
export const url = (path: string) => {
if (path.startsWith('http')) {
diff --git a/apps/public/src/lib/metadata.ts b/apps/public/src/lib/metadata.ts
index 8e49d92c..487dc8ae 100644
--- a/apps/public/src/lib/metadata.ts
+++ b/apps/public/src/lib/metadata.ts
@@ -1,9 +1,9 @@
import type { Metadata } from 'next';
+import { OPENPANEL_DESCRIPTION, OPENPANEL_NAME } from './openpanel-brand';
import { url as baseUrl } from './layout.shared';
-const siteName = 'OpenPanel';
-const defaultDescription =
- 'OpenPanel is a simple, affordable open-source alternative to Mixpanel for web and product analytics. Get powerful insights without the complexity.';
+const siteName = OPENPANEL_NAME;
+const defaultDescription = OPENPANEL_DESCRIPTION;
const defaultImage = baseUrl('/ogimage.png');
export function getOgImageUrl(url: string): string {
diff --git a/apps/public/src/lib/openpanel-brand.ts b/apps/public/src/lib/openpanel-brand.ts
new file mode 100644
index 00000000..bff22772
--- /dev/null
+++ b/apps/public/src/lib/openpanel-brand.ts
@@ -0,0 +1,4 @@
+export const OPENPANEL_NAME = 'OpenPanel';
+export const OPENPANEL_BASE_URL = 'https://openpanel.dev';
+export const OPENPANEL_DESCRIPTION =
+ 'OpenPanel is an open-source web and product analytics platform, an open-source alternative to Mixpanel with optional self-hosting.';
diff --git a/apps/public/src/lib/source.ts b/apps/public/src/lib/source.ts
index 74ebc801..e0564955 100644
--- a/apps/public/src/lib/source.ts
+++ b/apps/public/src/lib/source.ts
@@ -10,6 +10,7 @@ import {
import { type InferPageType, loader } from 'fumadocs-core/source';
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
import { toFumadocsSource } from 'fumadocs-mdx/runtime/server';
+import { OPENPANEL_BASE_URL } from './openpanel-brand';
import type { CompareData } from './compare';
import type { FeatureData } from './features';
import { loadFeatureSourceSync } from './features';
@@ -49,8 +50,11 @@ export function getPageImage(page: InferPageType) {
export async function getLLMText(page: InferPageType) {
const processed = await page.data.getText('processed');
+ const canonical = `${OPENPANEL_BASE_URL}${page.url}`;
- return `# ${page.data.title}
+ return `---
+## ${page.data.title}
+URL: ${canonical}
${processed}`;
}
diff --git a/apps/public/src/middleware.ts b/apps/public/src/middleware.ts
new file mode 100644
index 00000000..fb78d6b7
--- /dev/null
+++ b/apps/public/src/middleware.ts
@@ -0,0 +1,17 @@
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+
+export function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+ if (pathname.endsWith('.md')) {
+ const url = request.nextUrl.clone();
+ url.pathname = '/md';
+ url.searchParams.set('path', pathname.slice(0, -3));
+ return NextResponse.rewrite(url);
+ }
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: ['/((?!_next|api|favicon|robots|sitemap|og).*\\.md)'],
+};