diff --git a/CHANGELOG.md b/CHANGELOG.md
index 678b847..c2c7244 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **No more loading flicker.** Fast-loading pages no longer flash a loading state for a split second before showing content.
- **Clearer error messages.** When something goes wrong, the error message now tells you what failed (e.g. "Failed to load uptime monitors") instead of a generic "Failed to load data".
- **Faster favicon loading.** Site icons in the dashboard, referrers, and campaigns now use Next.js image optimization for better caching and lazy loading.
+- **Better page titles.** Browser tabs now show which site and page you're on (e.g. "Uptime · example.com | Pulse") instead of the same generic title everywhere.
+- **Link previews for public dashboards.** Sharing a public dashboard link on social media now shows a proper preview with the site name and description.
## [0.10.0-alpha] - 2026-02-21
diff --git a/app/about/layout.tsx b/app/about/layout.tsx
new file mode 100644
index 0000000..cb91b21
--- /dev/null
+++ b/app/about/layout.tsx
@@ -0,0 +1,19 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'About | Pulse',
+ description: 'Pulse is built by Ciphera — privacy-first web analytics made in Switzerland.',
+ openGraph: {
+ title: 'About | Pulse',
+ description: 'Pulse is built by Ciphera — privacy-first web analytics made in Switzerland.',
+ siteName: 'Pulse by Ciphera',
+ },
+}
+
+export default function AboutLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/faq/layout.tsx b/app/faq/layout.tsx
new file mode 100644
index 0000000..696202e
--- /dev/null
+++ b/app/faq/layout.tsx
@@ -0,0 +1,19 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'FAQ | Pulse',
+ description: 'Frequently asked questions about Pulse, privacy, GDPR compliance, and how it works.',
+ openGraph: {
+ title: 'FAQ | Pulse',
+ description: 'Frequently asked questions about Pulse, privacy, GDPR compliance, and how it works.',
+ siteName: 'Pulse by Ciphera',
+ },
+}
+
+export default function FaqLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/features/layout.tsx b/app/features/layout.tsx
new file mode 100644
index 0000000..9ec58ac
--- /dev/null
+++ b/app/features/layout.tsx
@@ -0,0 +1,19 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Features | Pulse',
+ description: 'Dashboards, funnels, uptime monitoring, realtime visitors, and more — all without cookies.',
+ openGraph: {
+ title: 'Features | Pulse',
+ description: 'Dashboards, funnels, uptime monitoring, realtime visitors, and more — all without cookies.',
+ siteName: 'Pulse by Ciphera',
+ },
+}
+
+export default function FeaturesLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/integrations/layout.tsx b/app/integrations/layout.tsx
new file mode 100644
index 0000000..52a43b0
--- /dev/null
+++ b/app/integrations/layout.tsx
@@ -0,0 +1,19 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Integrations | Pulse',
+ description: 'Add Pulse analytics to Next.js, React, Vue, WordPress, and more in under a minute.',
+ openGraph: {
+ title: 'Integrations | Pulse',
+ description: 'Add Pulse analytics to Next.js, React, Vue, WordPress, and more in under a minute.',
+ siteName: 'Pulse by Ciphera',
+ },
+}
+
+export default function IntegrationsLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/notifications/layout.tsx b/app/notifications/layout.tsx
new file mode 100644
index 0000000..40928a2
--- /dev/null
+++ b/app/notifications/layout.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Notifications | Pulse',
+ description: 'View your alerts and activity updates.',
+ robots: { index: false, follow: false },
+}
+
+export default function NotificationsLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx
index 6163ba1..f31aa18 100644
--- a/app/pricing/page.tsx
+++ b/app/pricing/page.tsx
@@ -1,7 +1,18 @@
import { Suspense } from 'react'
+import type { Metadata } from 'next'
import PricingSection from '@/components/PricingSection'
import { PricingCardsSkeleton } from '@/components/skeletons'
+export const metadata: Metadata = {
+ title: 'Pricing | Pulse',
+ description: 'Simple, transparent pricing for privacy-first web analytics. Free tier included.',
+ openGraph: {
+ title: 'Pricing | Pulse',
+ description: 'Simple, transparent pricing for privacy-first web analytics. Free tier included.',
+ siteName: 'Pulse by Ciphera',
+ },
+}
+
export default function PricingPage() {
return (
diff --git a/app/share/[id]/layout.tsx b/app/share/[id]/layout.tsx
new file mode 100644
index 0000000..0071b2d
--- /dev/null
+++ b/app/share/[id]/layout.tsx
@@ -0,0 +1,72 @@
+import type { Metadata } from 'next'
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082'
+
+interface SharePageParams {
+ params: Promise<{ id: string }>
+}
+
+export async function generateMetadata({ params }: SharePageParams): Promise {
+ const { id } = await params
+ const fallback: Metadata = {
+ title: 'Public Dashboard | Pulse',
+ description: 'Privacy-first web analytics — view this site\'s public stats.',
+ openGraph: {
+ title: 'Public Dashboard | Pulse',
+ description: 'Privacy-first web analytics — view this site\'s public stats.',
+ siteName: 'Pulse by Ciphera',
+ type: 'website',
+ },
+ twitter: {
+ card: 'summary',
+ title: 'Public Dashboard | Pulse',
+ description: 'Privacy-first web analytics — view this site\'s public stats.',
+ },
+ }
+
+ try {
+ const res = await fetch(`${API_URL}/public/sites/${id}/dashboard?limit=1`, {
+ next: { revalidate: 3600 },
+ })
+ if (!res.ok) return fallback
+
+ const data = await res.json()
+ const domain = data?.site?.domain
+ if (!domain) return fallback
+
+ const title = `${domain} analytics | Pulse`
+ const description = `Live, privacy-first analytics for ${domain} — powered by Pulse.`
+
+ return {
+ title,
+ description,
+ openGraph: {
+ title,
+ description,
+ siteName: 'Pulse by Ciphera',
+ type: 'website',
+ images: [{
+ url: `https://www.google.com/s2/favicons?domain=${domain}&sz=128`,
+ width: 128,
+ height: 128,
+ alt: `${domain} favicon`,
+ }],
+ },
+ twitter: {
+ card: 'summary',
+ title,
+ description,
+ },
+ }
+ } catch {
+ return fallback
+ }
+}
+
+export default function ShareLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/sites/[id]/funnels/layout.tsx b/app/sites/[id]/funnels/layout.tsx
new file mode 100644
index 0000000..fe54962
--- /dev/null
+++ b/app/sites/[id]/funnels/layout.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Funnels | Pulse',
+ description: 'Track conversion funnels and user journeys.',
+ robots: { index: false, follow: false },
+}
+
+export default function FunnelsLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/sites/[id]/layout.tsx b/app/sites/[id]/layout.tsx
new file mode 100644
index 0000000..6b377d3
--- /dev/null
+++ b/app/sites/[id]/layout.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Dashboard | Pulse',
+ description: 'View your site analytics, traffic, and performance.',
+ robots: { index: false, follow: false },
+}
+
+export default function SiteLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx
index 7010baa..689b4b0 100644
--- a/app/sites/[id]/page.tsx
+++ b/app/sites/[id]/page.tsx
@@ -216,6 +216,10 @@ export default function SiteDashboardPage() {
return () => clearInterval(interval)
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, loadRealtime])
+ useEffect(() => {
+ if (site?.domain) document.title = `${site.domain} | Pulse`
+ }, [site?.domain])
+
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) {
diff --git a/app/sites/[id]/realtime/layout.tsx b/app/sites/[id]/realtime/layout.tsx
new file mode 100644
index 0000000..64b256b
--- /dev/null
+++ b/app/sites/[id]/realtime/layout.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Realtime | Pulse',
+ description: 'See who is on your site right now.',
+ robots: { index: false, follow: false },
+}
+
+export default function RealtimeLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/sites/[id]/realtime/page.tsx b/app/sites/[id]/realtime/page.tsx
index 632045b..2ddced3 100644
--- a/app/sites/[id]/realtime/page.tsx
+++ b/app/sites/[id]/realtime/page.tsx
@@ -91,6 +91,10 @@ export default function RealtimePage() {
}
}
+ useEffect(() => {
+ if (site?.domain) document.title = `Realtime · ${site.domain} | Pulse`
+ }, [site?.domain])
+
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) return
diff --git a/app/sites/[id]/settings/layout.tsx b/app/sites/[id]/settings/layout.tsx
new file mode 100644
index 0000000..6a44c2e
--- /dev/null
+++ b/app/sites/[id]/settings/layout.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Site Settings | Pulse',
+ description: 'Configure your site tracking, privacy, and goals.',
+ robots: { index: false, follow: false },
+}
+
+export default function SiteSettingsLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx
index 9c90e27..2ff7514 100644
--- a/app/sites/[id]/settings/page.tsx
+++ b/app/sites/[id]/settings/page.tsx
@@ -317,6 +317,10 @@ export default function SiteSettingsPage() {
setTimeout(() => setSnippetCopied(false), 2000)
}
+ useEffect(() => {
+ if (site?.domain) document.title = `Settings · ${site.domain} | Pulse`
+ }, [site?.domain])
+
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) {
diff --git a/app/sites/[id]/uptime/layout.tsx b/app/sites/[id]/uptime/layout.tsx
new file mode 100644
index 0000000..af93f93
--- /dev/null
+++ b/app/sites/[id]/uptime/layout.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Uptime | Pulse',
+ description: 'Monitor your site uptime and response times.',
+ robots: { index: false, follow: false },
+}
+
+export default function UptimeLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return children
+}
diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx
index 2ead55b..b00a060 100644
--- a/app/sites/[id]/uptime/page.tsx
+++ b/app/sites/[id]/uptime/page.tsx
@@ -703,6 +703,10 @@ export default function UptimePage() {
setShowEditModal(true)
}
+ useEffect(() => {
+ if (site?.domain) document.title = `Uptime · ${site.domain} | Pulse`
+ }, [site?.domain])
+
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) return