feat: enhance SurfSense with new skills, blog section, and improve SEO metadata
Some checks failed
Build and Push Docker Images / tag_release (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Has been cancelled
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Has been cancelled

- Added multiple new skills to skills-lock.json from the repository `aaron-he-zhu/seo-geo-claude-skills`.
- Introduced `fuzzy-search` dependency in package.json for improved search functionality.
- Updated pnpm-lock.yaml to include the new `fuzzy-search` package.
- Enhanced SEO metadata across various pages, including canonical links and descriptions for better search visibility.
- Improved layout and structure of several components, including the homepage and changelog, to enhance user experience.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-04-11 23:38:12 -07:00
parent 61b3f0d7e3
commit 7ea840dbb2
120 changed files with 25729 additions and 352 deletions

View file

@ -0,0 +1,163 @@
import { loader } from "fumadocs-core/source";
import type { Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation";
import { blog } from "@/.source/server";
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
import { ArticleJsonLd } from "@/components/seo/json-ld";
import { formatDate } from "@/lib/utils";
import { getMDXComponents } from "@/mdx-components";
const source = loader({
baseUrl: "/blog",
source: blog.toFumadocsSource(),
});
interface BlogData {
title: string;
description: string;
date: string;
image?: string;
author?: string;
authorAvatar?: string;
tags?: string[];
body: React.ComponentType<{
components?: Record<string, React.ComponentType>;
}>;
}
interface BlogPageItem {
url: string;
slugs: string[];
data: BlogData;
}
export async function generateStaticParams() {
return source.getPages().map((page) => ({
slug: (page as BlogPageItem).slugs.join("/"),
}));
}
export async function generateMetadata(props: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await props.params;
const page = (source.getPages() as BlogPageItem[]).find(
(p) => p.slugs.join("/") === slug,
);
if (!page) return {};
return {
title: `${page.data.title} | SurfSense Blog`,
description: page.data.description,
alternates: {
canonical: `https://surfsense.com/blog/${slug}`,
},
openGraph: {
title: page.data.title,
description: page.data.description,
type: "article",
publishedTime: page.data.date,
authors: [page.data.author ?? "SurfSense Team"],
tags: page.data.tags,
images: page.data.image ? [{ url: page.data.image }] : [{ url: "/og-image.png" }],
},
twitter: {
card: "summary_large_image",
title: page.data.title,
description: page.data.description,
images: page.data.image ? [page.data.image] : ["/og-image.png"],
},
};
}
export default async function BlogPostPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
const page = (source.getPages() as BlogPageItem[]).find(
(p) => p.slugs.join("/") === slug,
);
if (!page) notFound();
const MDX = page.data.body;
const date = new Date(page.data.date);
return (
<div className="min-h-screen relative pt-20">
<ArticleJsonLd
title={page.data.title}
description={page.data.description}
url={`https://surfsense.com/blog/${slug}`}
datePublished={page.data.date}
author={page.data.author ?? "SurfSense Team"}
image={page.data.image ? `https://surfsense.com${page.data.image}` : undefined}
/>
<div className="max-w-3xl mx-auto px-6 lg:px-10 pt-10 pb-20">
<BreadcrumbNav
items={[
{ name: "Home", href: "/" },
{ name: "Blog", href: "/blog" },
{ name: page.data.title, href: `/blog/${slug}` },
]}
className="mb-8"
/>
{page.data.image && (
<div className="relative aspect-2/1 overflow-hidden rounded-2xl mb-8">
<Image
src={page.data.image}
alt={page.data.title}
fill
className="object-cover"
priority
sizes="(max-width: 768px) 100vw, 768px"
/>
</div>
)}
<div className="space-y-4 mb-10">
<h1 className="text-3xl md:text-4xl font-bold tracking-tight text-balance">
{page.data.title}
</h1>
{page.data.tags && page.data.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{page.data.tags.map((tag: string) => (
<span
key={tag}
className="h-6 w-fit px-2.5 text-xs font-medium bg-muted text-muted-foreground rounded-full border flex items-center justify-center"
>
{tag}
</span>
))}
</div>
)}
<div className="flex items-center gap-3 text-sm text-muted-foreground">
{page.data.authorAvatar && (
<Image
src={page.data.authorAvatar}
alt={page.data.author ?? "SurfSense Team"}
width={32}
height={32}
className="h-8 w-8 rounded-full object-cover"
/>
)}
<span className="font-medium text-foreground">
{page.data.author ?? "SurfSense Team"}
</span>
<span>·</span>
<time dateTime={page.data.date}>{formatDate(date)}</time>
</div>
</div>
<div className="prose dark:prose-invert max-w-none prose-headings:scroll-mt-8 prose-headings:font-semibold prose-a:no-underline prose-headings:tracking-tight prose-headings:text-balance prose-p:tracking-tight prose-p:text-balance prose-img:rounded-xl prose-img:shadow-lg">
<MDX components={getMDXComponents()} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,228 @@
"use client";
import { Container } from "@/components/container";
import { format } from "date-fns";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import FuzzySearch from "fuzzy-search";
import type { BlogEntry } from "./page";
function truncate(text: string, length: number) {
return text.length > length ? `${text.slice(0, length)}` : text;
}
function SearchIcon({ className }: { className?: string }) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
);
}
export function BlogWithSearchMagazine({ blogs }: { blogs: BlogEntry[] }) {
const featured = blogs[0];
if (!featured) {
return (
<div className="relative overflow-hidden bg-neutral-50 px-4 md:px-8 dark:bg-neutral-950">
<Container className="relative pt-12 pb-24 md:pt-20">
<p className="text-center text-neutral-500">No blog posts yet.</p>
</Container>
</div>
);
}
return (
<div className="relative overflow-hidden bg-neutral-50 px-4 pt-20 md:px-8 dark:bg-neutral-950">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(120,119,198,0.15),transparent)] dark:bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(120,119,198,0.12),transparent)]" />
<Container className="relative pt-12 pb-24 md:pt-20">
<header className="mb-10 md:mb-14">
<h1 className="text-4xl font-bold tracking-tight text-neutral-900 md:text-5xl dark:text-neutral-50">
Blog
</h1>
</header>
<MagazineFeatured blog={featured} />
<MagazineSearchGrid blogs={blogs} featuredSlug={featured.slug} />
</Container>
</div>
);
}
function MagazineFeatured({ blog }: { blog: BlogEntry }) {
return (
<Link
href={blog.url}
className="group/cover relative mb-14 block overflow-hidden rounded-3xl border border-neutral-200/80 bg-neutral-900 shadow-sm dark:border-neutral-800 dark:shadow-none"
>
<div className="md:aspect-[2.4/1] relative aspect-21/9 min-h-[220px]">
{blog.image ? (
<img
src={blog.image}
alt={blog.title}
className="h-full w-full object-cover transition duration-500 group-hover/cover:scale-[1.03]"
/>
) : null}
<div className="absolute inset-0 bg-linear-to-t from-black/85 via-black/35 to-transparent" />
<div className="absolute inset-0 flex flex-col justify-end p-6 md:p-10">
<span className="mb-2 inline-flex w-fit rounded-full bg-white/15 px-3 py-1 text-xs font-medium text-white backdrop-blur-sm">
Cover story
</span>
<h2 className="max-w-3xl font-serif text-2xl leading-tight font-medium text-white md:text-4xl">
{blog.title}
</h2>
<p className="mt-3 max-w-2xl text-sm text-white/85 md:text-base">
{truncate(blog.description, 160)}
</p>
<div className="mt-5 flex flex-wrap items-center gap-3 text-sm text-white/90">
<span className="flex items-center gap-2">
<img
src={blog.authorAvatar}
alt={blog.author}
width={28}
height={28}
className="h-7 w-7 rounded-full ring-2 ring-white/30"
/>
{blog.author}
</span>
<span className="text-white/50">·</span>
<time dateTime={blog.date}>
{format(new Date(blog.date), "MMMM d, yyyy")}
</time>
</div>
</div>
</div>
</Link>
);
}
function MagazineSearchGrid({
blogs: allBlogs,
featuredSlug,
}: {
blogs: BlogEntry[];
featuredSlug: string;
}) {
const [search, setSearch] = useState("");
const searcher = useMemo(
() =>
new FuzzySearch(allBlogs, ["title", "description"], {
caseSensitive: false,
}),
[allBlogs],
);
const [results, setResults] = useState(allBlogs);
useEffect(() => {
setResults(searcher.search(search));
}, [search, searcher]);
const gridItems = useMemo(() => {
if (search.trim()) {
return results;
}
return results.filter((b) => b.slug !== featuredSlug);
}, [results, search, featuredSlug]);
return (
<section aria-labelledby="archive-heading">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h2
id="archive-heading"
className="font-serif text-2xl font-medium text-neutral-900 dark:text-neutral-100"
>
From the archive
</h2>
<label className="relative w-full sm:max-w-md">
<span className="sr-only">Search articles</span>
<SearchIcon className="pointer-events-none absolute top-1/2 left-4 -translate-y-1/2 text-neutral-400" />
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by title or topic…"
className="w-full rounded-full bg-white py-3 pr-4 pl-12 text-sm text-neutral-800 shadow-sm ring-1 shadow-black/10 ring-black/10 transition outline-none placeholder:text-neutral-400 focus:border-neutral-400 focus:ring-2 focus:ring-neutral-200/80 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder:text-neutral-500 dark:focus:border-neutral-500 dark:focus:ring-neutral-700/50"
/>
</label>
</div>
{gridItems.length === 0 ? (
<p className="rounded-2xl border border-dashed border-neutral-300 py-16 text-center text-neutral-500 dark:border-neutral-700 dark:text-neutral-400">
No articles match that search.
</p>
) : (
<ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{gridItems.map((blog) => (
<li key={blog.slug}>
<MagazineCard blog={blog} />
</li>
))}
</ul>
)}
</section>
);
}
function MagazineCard({ blog }: { blog: BlogEntry }) {
return (
<Link
href={blog.url}
className="group/card flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-sm ring-1 shadow-black/10 ring-black/10 transition hover:-translate-y-0.5 hover:shadow-lg dark:border-neutral-800 dark:bg-neutral-900/50 dark:hover:border-neutral-700"
>
<div className="relative aspect-16/10 overflow-hidden bg-neutral-100 dark:bg-neutral-800">
{blog.image ? (
<img
src={blog.image}
alt={blog.title}
className="h-full w-full object-cover transition duration-300 group-hover/card:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center text-neutral-400">
No image
</div>
)}
</div>
<div className="flex flex-1 flex-col p-5">
<time
className="text-xs font-medium tracking-wider text-neutral-500 uppercase dark:text-neutral-400"
dateTime={blog.date}
>
{format(new Date(blog.date), "MMM d, yyyy")}
</time>
<h3 className="mt-2 font-serif text-lg leading-snug font-medium text-neutral-900 dark:text-neutral-100">
{blog.title}
</h3>
<p className="mt-2 flex-1 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
{truncate(blog.description, 110)}
</p>
<div className="mt-4 flex items-center gap-2 pt-4">
<img
src={blog.authorAvatar}
alt={blog.author}
width={24}
height={24}
className="h-6 w-6 rounded-full object-cover"
/>
<span className="text-xs text-neutral-600 dark:text-neutral-300">
{blog.author}
</span>
</div>
</div>
</Link>
);
}

View file

@ -0,0 +1,61 @@
import { loader } from "fumadocs-core/source";
import type { Metadata } from "next";
import { blog } from "@/.source/server";
import { BlogWithSearchMagazine } from "./blog-magazine";
export const metadata: Metadata = {
title: "Blog | SurfSense - AI Search & Knowledge Management",
description:
"Product updates, tutorials, and tips from the SurfSense team.",
alternates: {
canonical: "https://surfsense.com/blog",
},
};
const source = loader({
baseUrl: "/blog",
source: blog.toFumadocsSource(),
});
export interface BlogEntry {
title: string;
description: string;
date: string;
slug: string;
url: string;
image: string;
author: string;
authorAvatar: string;
}
export default async function BlogPage() {
const allPages = source.getPages() as Array<{
url: string;
slugs: string[];
data: {
title: string;
description: string;
date: string;
image?: string;
author?: string;
authorAvatar?: string;
};
}>;
const blogs: BlogEntry[] = allPages
.map((page) => ({
title: page.data.title,
description: page.data.description ?? "",
date: page.data.date,
slug: page.slugs.join("/"),
url: page.url,
image: page.data.image ?? "/og-image.png",
author: page.data.author ?? "SurfSense Team",
authorAvatar: page.data.authorAvatar ?? "/logo.png",
}))
.sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
return <BlogWithSearchMagazine blogs={blogs} />;
}

View file

@ -1,12 +1,16 @@
import { loader } from "fumadocs-core/source";
import type { Metadata } from "next";
import { changelog } from "@/.source/server";
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
import { formatDate } from "@/lib/utils";
import { getMDXComponents } from "@/mdx-components";
export const metadata: Metadata = {
title: "Changelog | SurfSense",
description: "See what's new in SurfSense.",
description: "See what's new in SurfSense. Latest updates, features, and improvements.",
alternates: {
canonical: "https://surfsense.com/changelog",
},
};
const source = loader({
@ -42,6 +46,13 @@ export default async function ChangelogPage() {
<div className="max-w-5xl mx-auto relative">
<div className="p-6 flex items-center justify-between">
<div>
<BreadcrumbNav
items={[
{ name: "Home", href: "/" },
{ name: "Changelog", href: "/changelog" },
]}
className="mb-4"
/>
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 bg-clip-text text-transparent">
Changelog
</h1>

View file

@ -3,7 +3,10 @@ import { ContactFormGridWithDetails } from "@/components/contact/contact-form";
export const metadata: Metadata = {
title: "Contact | SurfSense",
description: "Get in touch with the SurfSense team.",
description: "Get in touch with the SurfSense team for enterprise AI search, knowledge management, or partnership inquiries.",
alternates: {
canonical: "https://surfsense.com/contact",
},
};
const page = () => {

View file

@ -1,49 +1,23 @@
"use client";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { HeroSection } from "@/components/homepage/hero-section";
import { getBearerToken } from "@/lib/auth-utils";
import { AuthRedirect } from "@/components/homepage/auth-redirect";
import { FeaturesCards } from "@/components/homepage/features-card";
import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid";
const WhySurfSense = dynamic(
() => import("@/components/homepage/why-surfsense").then((m) => ({ default: m.WhySurfSense })),
{ ssr: false }
);
const FeaturesCards = dynamic(
() => import("@/components/homepage/features-card").then((m) => ({ default: m.FeaturesCards })),
{ ssr: false }
);
const FeaturesBentoGrid = dynamic(
() =>
import("@/components/homepage/features-bento-grid").then((m) => ({
default: m.FeaturesBentoGrid,
})),
{ ssr: false }
);
const ExternalIntegrations = dynamic(() => import("@/components/homepage/integrations"), {
ssr: false,
});
const ExternalIntegrations = dynamic(() => import("@/components/homepage/integrations"));
const CTAHomepage = dynamic(
() => import("@/components/homepage/cta").then((m) => ({ default: m.CTAHomepage })),
{ ssr: false }
);
export default function HomePage() {
const router = useRouter();
useEffect(() => {
if (getBearerToken()) {
router.replace("/dashboard");
}
}, [router]);
return (
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
<AuthRedirect />
<HeroSection />
<WhySurfSense />
<FeaturesCards />

View file

@ -1,14 +1,27 @@
import type { Metadata } from "next";
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
import PricingBasic from "@/components/pricing/pricing-section";
export const metadata: Metadata = {
title: "Pricing | SurfSense",
description: "Explore SurfSense plans and pricing options.",
title: "Pricing | SurfSense - Free AI Search Plans",
description:
"Explore SurfSense plans and pricing. Use ChatGPT, Claude AI, and any AI model free. Open source NotebookLM alternative for teams.",
alternates: {
canonical: "https://surfsense.com/pricing",
},
};
const page = () => {
return (
<div>
<div className="container mx-auto pt-24 px-4">
<BreadcrumbNav
items={[
{ name: "Home", href: "/" },
{ name: "Pricing", href: "/pricing" },
]}
/>
</div>
<PricingBasic />
</div>
);

View file

@ -3,6 +3,9 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Privacy Policy | SurfSense",
description: "Privacy Policy for SurfSense application",
alternates: {
canonical: "https://surfsense.com/privacy",
},
};
export default function PrivacyPolicy() {

View file

@ -3,6 +3,9 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Terms of Service | SurfSense",
description: "Terms of Service for SurfSense application",
alternates: {
canonical: "https://surfsense.com/terms",
},
};
export default function TermsOfService() {

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View file

@ -45,8 +45,17 @@ export async function generateMetadata(props: { params: Promise<{ slug?: string[
const page = getDocPage(params.slug);
if (!page) notFound();
const slugPath = params.slug ? params.slug.join("/") : "";
return {
title: page.data.title,
title: `${page.data.title} | SurfSense Docs`,
description: page.data.description,
alternates: {
canonical: `https://surfsense.com/docs${slugPath ? `/${slugPath}` : ""}`,
},
openGraph: {
title: `${page.data.title} | SurfSense Docs`,
description: page.data.description,
type: "article",
},
};
}

BIN
surfsense_web/app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View file

@ -13,6 +13,7 @@ import { LocaleProvider } from "@/contexts/LocaleContext";
import { PlatformProvider } from "@/contexts/platform-context";
import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider";
import { cn } from "@/lib/utils";
import { OrganizationJsonLd, SoftwareApplicationJsonLd, WebSiteJsonLd } from "@/components/seo/json-ld";
const roboto = Roboto({
subsets: ["latin"],
@ -36,82 +37,81 @@ export const viewport: Viewport = {
};
export const metadata: Metadata = {
title: "SurfSense - Open Source NotebookLM Alternative for Teams",
metadataBase: new URL("https://surfsense.com"),
alternates: {
canonical: "https://surfsense.com",
},
title: "SurfSense - NotebookLM Alternative | Free ChatGPT & Claude AI",
description:
"An open source, privacy focused alternative to NotebookLM for teams with no data limits, built for enterprise AI search and knowledge management.",
"Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.",
keywords: [
"enterprise ai",
"enterprise search",
"enterprise search software",
"chatgpt alternative free",
"ai enterprise search",
"enterprise search solutions",
"intranet search engine",
"federated search",
"enterprise search engine",
"what is enterprise search",
"enterprise knowledge management software",
"free chatgpt alternative",
"chatgpt free alternative",
"best enterprise search software",
"enterprise ai search",
"enterprise knowledge management",
"federated search engine",
"enterprise knowledge management system",
"free claude ai",
"what is enterprise search engine marketing",
"ai driven enterprise search",
"free alternative to chatgpt",
"free claude",
"alternative to chatgpt free",
"free ai chatbot like chatgpt",
"enterprise search software comparison",
"apps like chatgpt for free",
"free chatgpt no login",
"free ai chatbots like chatgpt",
"enterprise document search",
"search engine for intranet",
"chatgpt online",
"online chatgpt",
"chat gpt free",
"chatgpt free",
"free chatgpt",
"free chat gpt",
"chatgpt no login",
"chatgpt online free",
"chatgpt free online",
"chatgpt without login",
"free chatgpt without login",
"unified search engine",
"free chatgpt no login",
"chatgpt for free",
"claude ai free",
"claude free",
"free claude ai",
"free claude",
"chatgpt alternative free",
"free chatgpt alternative",
"free alternative to chatgpt",
"alternative to chatgpt free",
"chatgpt alternative online free",
"ai like chatgpt",
"sites like chatgpt",
"free ai chatbot like chatgpt",
"apps like chatgpt for free",
"free ai chatbots like chatgpt",
"best free alternative to chatgpt",
"free chatgpt alternative app",
"free chatgpt alternative with image upload",
"best free alternative to chatgpt",
"enterprise search engine open source",
"open source notebooklm alternative",
"free ai apps",
"ai with no restrictions",
"notebooklm alternative",
"notebooklm alternative for teams",
"open source notebooklm alternative",
"SurfSense",
],
openGraph: {
title: "SurfSense - Open Source NotebookLM Alternative for Teams",
title: "SurfSense - NotebookLM Alternative | Free ChatGPT & Claude AI",
description:
"An open source, privacy focused alternative to NotebookLM for teams with no data limits. Open source enterprise AI search and knowledge management.",
"Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude, and any AI model for free.",
url: "https://surfsense.com",
siteName: "SurfSense",
type: "website",
images: [
{
url: "https://surfsense.com/og-image.png",
url: "/og-image.png",
width: 1200,
height: 630,
alt: "SurfSense - Open Source NotebookLM Alternative for Teams",
alt: "SurfSense - Open Source NotebookLM Alternative with Free ChatGPT and Claude AI",
},
],
locale: "en_US",
},
twitter: {
card: "summary_large_image",
title: "SurfSense - Open Source NotebookLM Alternative for Teams",
title: "SurfSense - NotebookLM Alternative | Free ChatGPT & Claude AI",
description:
"An open source, privacy focused alternative to NotebookLM for teams with no data limits. Open source enterprise AI search and knowledge management.",
creator: "https://surfsense.com",
site: "https://surfsense.com",
"Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.",
creator: "@SurfSenseAI",
site: "@SurfSenseAI",
images: [
{
url: "https://surfsense.com/og-image-twitter.png",
url: "/og-image-twitter.png",
width: 1200,
height: 630,
alt: "SurfSense - Open Source NotebookLM Alternative for Teams",
alt: "SurfSense - Open Source NotebookLM Alternative with Free ChatGPT and Claude AI",
},
],
},
@ -129,6 +129,9 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://api.github.com" />
<OrganizationJsonLd />
<WebSiteJsonLd />
<SoftwareApplicationJsonLd />
</head>
<body className={cn(roboto.className, "bg-white dark:bg-black antialiased h-full w-full ")}>
<PostHogProvider>

View file

@ -0,0 +1,54 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Page Not Found | SurfSense",
description: "The page you're looking for doesn't exist. Explore SurfSense - open source enterprise AI search and knowledge management.",
};
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-4 text-center">
<h1 className="text-8xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100">
404
</h1>
<p className="mt-4 text-xl text-neutral-600 dark:text-neutral-400">
The page you&apos;re looking for doesn&apos;t exist.
</p>
<p className="mt-2 text-base text-neutral-500 dark:text-neutral-500">
It may have been moved, or the URL might be incorrect.
</p>
<div className="mt-10 flex flex-col items-center gap-4 sm:flex-row">
<Link
href="/"
className="rounded-lg bg-black px-6 py-3 text-sm font-medium text-white transition hover:bg-neutral-800 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
Go Home
</Link>
<Link
href="/docs"
className="rounded-lg border border-neutral-200 px-6 py-3 text-sm font-medium text-neutral-700 transition hover:bg-neutral-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
Browse Docs
</Link>
<Link
href="/blog"
className="rounded-lg border border-neutral-200 px-6 py-3 text-sm font-medium text-neutral-700 transition hover:bg-neutral-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
Read Blog
</Link>
</div>
<nav className="mt-16 flex flex-wrap justify-center gap-x-6 gap-y-2 text-sm text-neutral-500 dark:text-neutral-400">
<Link href="/pricing" className="hover:text-neutral-900 dark:hover:text-neutral-200">
Pricing
</Link>
<Link href="/contact" className="hover:text-neutral-900 dark:hover:text-neutral-200">
Contact
</Link>
<Link href="/changelog" className="hover:text-neutral-900 dark:hover:text-neutral-200">
Changelog
</Link>
</nav>
</div>
);
}

View file

@ -0,0 +1,22 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: [
"/dashboard/",
"/desktop/",
"/auth/",
"/api/",
"/invite/",
"/public/",
"/verify-token/",
],
},
],
sitemap: "https://surfsense.com/sitemap.xml",
};
}

View file

@ -1,259 +1,59 @@
import { loader } from "fumadocs-core/source";
import type { MetadataRoute } from "next";
import { blog, changelog } from "@/.source/server";
import { source as docsSource } from "@/lib/source";
// Returns a date rounded to the current hour (updates only once per hour)
function getHourlyDate(): Date {
const now = new Date();
now.setMinutes(0, 0, 0);
return now;
}
const blogSource = loader({
baseUrl: "/blog",
source: blog.toFumadocsSource(),
});
const changelogSource = loader({
baseUrl: "/changelog",
source: changelog.toFumadocsSource(),
});
const BASE_URL = "https://surfsense.com";
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = getHourlyDate();
const now = new Date();
now.setMinutes(0, 0, 0);
const lastModified = now;
return [
{
url: "https://www.surfsense.com/",
lastModified,
changeFrequency: "daily",
priority: 1,
},
{
url: "https://www.surfsense.com/contact",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/pricing",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/privacy",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/terms",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
// Documentation pages
{
url: "https://www.surfsense.com/docs",
lastModified,
changeFrequency: "daily",
priority: 1,
},
{
url: "https://www.surfsense.com/docs/installation",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/prerequisites",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/docker-installation/install-script",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/docker-installation/docker-compose",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/docker-installation/updating",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/docker-installation/dev-compose",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/docker-installation/migrate-from-allinone",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
{
url: "https://www.surfsense.com/docs/manual-installation",
lastModified,
changeFrequency: "daily",
priority: 0.9,
},
// Connector documentation
{
url: "https://www.surfsense.com/docs/connectors/airtable",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/bookstack",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/circleback",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/clickup",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/confluence",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/discord",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/dropbox",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/elasticsearch",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/github",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/gmail",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/google-calendar",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/google-drive",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/jira",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/linear",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/luma",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/microsoft-onedrive",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/microsoft-teams",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/notion",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/obsidian",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/slack",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/connectors/web-crawler",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
// How-to documentation
{
url: "https://www.surfsense.com/docs/how-to/zero-sync",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/how-to/realtime-collaboration",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
{
url: "https://www.surfsense.com/docs/how-to/web-search",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
// Developer documentation
{
url: "https://www.surfsense.com/docs/testing",
lastModified,
changeFrequency: "daily",
priority: 0.7,
},
{
url: "https://www.surfsense.com/docs/code-of-conduct",
lastModified,
changeFrequency: "daily",
priority: 0.7,
},
const staticPages: MetadataRoute.Sitemap = [
{ url: `${BASE_URL}/`, lastModified, changeFrequency: "daily", priority: 1 },
{ url: `${BASE_URL}/pricing`, lastModified, changeFrequency: "weekly", priority: 0.9 },
{ url: `${BASE_URL}/contact`, lastModified, changeFrequency: "monthly", priority: 0.7 },
{ url: `${BASE_URL}/blog`, lastModified, changeFrequency: "daily", priority: 0.9 },
{ url: `${BASE_URL}/changelog`, lastModified, changeFrequency: "weekly", priority: 0.7 },
{ url: `${BASE_URL}/announcements`, lastModified, changeFrequency: "weekly", priority: 0.6 },
{ url: `${BASE_URL}/docs`, lastModified, changeFrequency: "daily", priority: 1 },
{ url: `${BASE_URL}/privacy`, lastModified, changeFrequency: "monthly", priority: 0.3 },
{ url: `${BASE_URL}/terms`, lastModified, changeFrequency: "monthly", priority: 0.3 },
{ url: `${BASE_URL}/login`, lastModified, changeFrequency: "monthly", priority: 0.5 },
{ url: `${BASE_URL}/register`, lastModified, changeFrequency: "monthly", priority: 0.5 },
];
const docsPages: MetadataRoute.Sitemap = docsSource.getPages().map((page) => ({
url: `${BASE_URL}${page.url}`,
lastModified,
changeFrequency: "weekly" as const,
priority: 0.8,
}));
const blogPages: MetadataRoute.Sitemap = blogSource.getPages().map((page) => ({
url: `${BASE_URL}${page.url}`,
lastModified,
changeFrequency: "weekly" as const,
priority: 0.8,
}));
const changelogPages: MetadataRoute.Sitemap = changelogSource.getPages().map((page) => ({
url: `${BASE_URL}${page.url}`,
lastModified,
changeFrequency: "monthly" as const,
priority: 0.5,
}));
return [...staticPages, ...docsPages, ...blogPages, ...changelogPages];
}