diff --git a/web/public/social-card.png b/web/public/social-card.png
index 57e6527..cae7ce0 100644
Binary files a/web/public/social-card.png and b/web/public/social-card.png differ
diff --git a/web/scripts/generate-sitemap.js b/web/scripts/generate-sitemap.js
index 4cf0387..b97ea17 100644
--- a/web/scripts/generate-sitemap.js
+++ b/web/scripts/generate-sitemap.js
@@ -12,7 +12,7 @@ const DIST_DIR = join(__dirname, "../dist/client");
const DEFAULT_SITE_URL = "https://openseo.so";
const SITE_URL = (process.env.SITE_URL ?? DEFAULT_SITE_URL).replace(/\/+$/, "");
-const STATIC_PATHS = new Set(["/", "/privacy", "/terms-and-conditions"]);
+const STATIC_PATHS = new Set(["/", "/pricing", "/privacy", "/terms-and-conditions"]);
function toCanonicalUrl(path) {
if (path === "/") {
diff --git a/web/src/components/site-footer.tsx b/web/src/components/site-footer.tsx
index cfb5581..c64867b 100644
--- a/web/src/components/site-footer.tsx
+++ b/web/src/components/site-footer.tsx
@@ -4,6 +4,7 @@ export function SiteFooter({ className }: { className?: string }) {
return (
+
OpenSEO
Discord
+
Pricing
Privacy
Terms
diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts
index 95068ad..f41971c 100644
--- a/web/src/routeTree.gen.ts
+++ b/web/src/routeTree.gen.ts
@@ -11,13 +11,14 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as TermsAndConditionsRouteImport } from './routes/terms-and-conditions'
import { Route as PrivacyRouteImport } from './routes/privacy'
-import { Route as PricingRouteImport } from './routes/pricing'
-import { Route as IndexRouteImport } from './routes/index'
+import { Route as MarketingRouteImport } from './routes/_marketing'
import { Route as BlogsIndexRouteImport } from './routes/blogs/index'
+import { Route as MarketingIndexRouteImport } from './routes/_marketing/index'
import { Route as JsScriptDotjsRouteImport } from './routes/js/script[.]js'
import { Route as BlogsSplatRouteImport } from './routes/blogs/$'
import { Route as ApiSubscribeRouteImport } from './routes/api/subscribe'
import { Route as ApiEventRouteImport } from './routes/api/event'
+import { Route as MarketingPricingRouteImport } from './routes/_marketing/pricing'
const TermsAndConditionsRoute = TermsAndConditionsRouteImport.update({
id: '/terms-and-conditions',
@@ -29,14 +30,8 @@ const PrivacyRoute = PrivacyRouteImport.update({
path: '/privacy',
getParentRoute: () => rootRouteImport,
} as any)
-const PricingRoute = PricingRouteImport.update({
- id: '/pricing',
- path: '/pricing',
- getParentRoute: () => rootRouteImport,
-} as any)
-const IndexRoute = IndexRouteImport.update({
- id: '/',
- path: '/',
+const MarketingRoute = MarketingRouteImport.update({
+ id: '/_marketing',
getParentRoute: () => rootRouteImport,
} as any)
const BlogsIndexRoute = BlogsIndexRouteImport.update({
@@ -44,6 +39,11 @@ const BlogsIndexRoute = BlogsIndexRouteImport.update({
path: '/blogs/',
getParentRoute: () => rootRouteImport,
} as any)
+const MarketingIndexRoute = MarketingIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => MarketingRoute,
+} as any)
const JsScriptDotjsRoute = JsScriptDotjsRouteImport.update({
id: '/js/script.js',
path: '/js/script.js',
@@ -64,12 +64,17 @@ const ApiEventRoute = ApiEventRouteImport.update({
path: '/api/event',
getParentRoute: () => rootRouteImport,
} as any)
+const MarketingPricingRoute = MarketingPricingRouteImport.update({
+ id: '/pricing',
+ path: '/pricing',
+ getParentRoute: () => MarketingRoute,
+} as any)
export interface FileRoutesByFullPath {
- '/': typeof IndexRoute
- '/pricing': typeof PricingRoute
+ '/': typeof MarketingIndexRoute
'/privacy': typeof PrivacyRoute
'/terms-and-conditions': typeof TermsAndConditionsRoute
+ '/pricing': typeof MarketingPricingRoute
'/api/event': typeof ApiEventRoute
'/api/subscribe': typeof ApiSubscribeRoute
'/blogs/$': typeof BlogsSplatRoute
@@ -77,35 +82,36 @@ export interface FileRoutesByFullPath {
'/blogs/': typeof BlogsIndexRoute
}
export interface FileRoutesByTo {
- '/': typeof IndexRoute
- '/pricing': typeof PricingRoute
'/privacy': typeof PrivacyRoute
'/terms-and-conditions': typeof TermsAndConditionsRoute
+ '/pricing': typeof MarketingPricingRoute
'/api/event': typeof ApiEventRoute
'/api/subscribe': typeof ApiSubscribeRoute
'/blogs/$': typeof BlogsSplatRoute
'/js/script.js': typeof JsScriptDotjsRoute
+ '/': typeof MarketingIndexRoute
'/blogs': typeof BlogsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
- '/': typeof IndexRoute
- '/pricing': typeof PricingRoute
+ '/_marketing': typeof MarketingRouteWithChildren
'/privacy': typeof PrivacyRoute
'/terms-and-conditions': typeof TermsAndConditionsRoute
+ '/_marketing/pricing': typeof MarketingPricingRoute
'/api/event': typeof ApiEventRoute
'/api/subscribe': typeof ApiSubscribeRoute
'/blogs/$': typeof BlogsSplatRoute
'/js/script.js': typeof JsScriptDotjsRoute
+ '/_marketing/': typeof MarketingIndexRoute
'/blogs/': typeof BlogsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
- | '/pricing'
| '/privacy'
| '/terms-and-conditions'
+ | '/pricing'
| '/api/event'
| '/api/subscribe'
| '/blogs/$'
@@ -113,31 +119,31 @@ export interface FileRouteTypes {
| '/blogs/'
fileRoutesByTo: FileRoutesByTo
to:
- | '/'
- | '/pricing'
| '/privacy'
| '/terms-and-conditions'
+ | '/pricing'
| '/api/event'
| '/api/subscribe'
| '/blogs/$'
| '/js/script.js'
+ | '/'
| '/blogs'
id:
| '__root__'
- | '/'
- | '/pricing'
+ | '/_marketing'
| '/privacy'
| '/terms-and-conditions'
+ | '/_marketing/pricing'
| '/api/event'
| '/api/subscribe'
| '/blogs/$'
| '/js/script.js'
+ | '/_marketing/'
| '/blogs/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
- IndexRoute: typeof IndexRoute
- PricingRoute: typeof PricingRoute
+ MarketingRoute: typeof MarketingRouteWithChildren
PrivacyRoute: typeof PrivacyRoute
TermsAndConditionsRoute: typeof TermsAndConditionsRoute
ApiEventRoute: typeof ApiEventRoute
@@ -163,18 +169,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PrivacyRouteImport
parentRoute: typeof rootRouteImport
}
- '/pricing': {
- id: '/pricing'
- path: '/pricing'
- fullPath: '/pricing'
- preLoaderRoute: typeof PricingRouteImport
- parentRoute: typeof rootRouteImport
- }
- '/': {
- id: '/'
- path: '/'
+ '/_marketing': {
+ id: '/_marketing'
+ path: ''
fullPath: '/'
- preLoaderRoute: typeof IndexRouteImport
+ preLoaderRoute: typeof MarketingRouteImport
parentRoute: typeof rootRouteImport
}
'/blogs/': {
@@ -184,6 +183,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof BlogsIndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/_marketing/': {
+ id: '/_marketing/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof MarketingIndexRouteImport
+ parentRoute: typeof MarketingRoute
+ }
'/js/script.js': {
id: '/js/script.js'
path: '/js/script.js'
@@ -212,12 +218,32 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiEventRouteImport
parentRoute: typeof rootRouteImport
}
+ '/_marketing/pricing': {
+ id: '/_marketing/pricing'
+ path: '/pricing'
+ fullPath: '/pricing'
+ preLoaderRoute: typeof MarketingPricingRouteImport
+ parentRoute: typeof MarketingRoute
+ }
}
}
+interface MarketingRouteChildren {
+ MarketingPricingRoute: typeof MarketingPricingRoute
+ MarketingIndexRoute: typeof MarketingIndexRoute
+}
+
+const MarketingRouteChildren: MarketingRouteChildren = {
+ MarketingPricingRoute: MarketingPricingRoute,
+ MarketingIndexRoute: MarketingIndexRoute,
+}
+
+const MarketingRouteWithChildren = MarketingRoute._addFileChildren(
+ MarketingRouteChildren,
+)
+
const rootRouteChildren: RootRouteChildren = {
- IndexRoute: IndexRoute,
- PricingRoute: PricingRoute,
+ MarketingRoute: MarketingRouteWithChildren,
PrivacyRoute: PrivacyRoute,
TermsAndConditionsRoute: TermsAndConditionsRoute,
ApiEventRoute: ApiEventRoute,
diff --git a/web/src/routes/_marketing.tsx b/web/src/routes/_marketing.tsx
new file mode 100644
index 0000000..2ec83f5
--- /dev/null
+++ b/web/src/routes/_marketing.tsx
@@ -0,0 +1,158 @@
+import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
+import { useState } from "react";
+import { SiteFooter } from "@/components/site-footer";
+
+function GitHubIcon({ size = 18 }: { size?: number }) {
+ return (
+
+
+
+ );
+}
+
+export const Route = createFileRoute("/_marketing")({
+ component: MarketingLayout,
+});
+
+function MarketingLayout() {
+ return (
+
+
+
+
+ OpenSEO
+
+
+
+
+
+
+
+ );
+}
+
+function useNewsletter() {
+ const [email, setEmail] = useState("");
+ const [status, setStatus] = useState<
+ "idle" | "loading" | "success" | "error"
+ >("idle");
+ const [errorMessage, setErrorMessage] = useState("");
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setStatus("loading");
+ setErrorMessage("");
+ try {
+ const res = await fetch("/api/subscribe", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(
+ (data as { error?: string }).error || "Something went wrong",
+ );
+ }
+ setStatus("success");
+ setEmail("");
+ } catch (err) {
+ setStatus("error");
+ setErrorMessage(
+ err instanceof Error ? err.message : "Something went wrong",
+ );
+ }
+ };
+
+ return { email, setEmail, status, errorMessage, handleSubmit };
+}
+
+function MarketingFooter() {
+ const nl = useNewsletter();
+
+ return (
+ <>
+ {/* Newsletter */}
+
+
+ Stay in the loop
+
+
+ Product updates, new features, and the occasional
+ behind-the-scenes.
+
+
+ {nl.status === "success" ? (
+
+ You're on the list.
+
+ ) : (
+
+ )}
+
+
+
+ {/* Footer */}
+
+
+
+ >
+ );
+}
diff --git a/web/src/routes/_marketing/index.tsx b/web/src/routes/_marketing/index.tsx
new file mode 100644
index 0000000..b8f3afe
--- /dev/null
+++ b/web/src/routes/_marketing/index.tsx
@@ -0,0 +1,126 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { buildPageSeo } from "@/lib/seo";
+
+const homeTitle = "OpenSEO - Open Source SEO Platform";
+const homeDescription =
+ "Own your SEO. OpenSEO helps teams manage keyword research, backlink analysis, competitor monitoring, and site audits without expensive monthly SEO software subscriptions.";
+
+export const Route = createFileRoute("/_marketing/")({
+ head: () =>
+ buildPageSeo({
+ title: homeTitle,
+ description: homeDescription,
+ path: "/",
+ imageAlt: "OpenSEO keyword research dashboard preview",
+ }),
+ component: Home,
+});
+
+// ─── Page ────────────────────────────────────────────────────────────
+
+function Home() {
+ return (
+ <>
+ {/* Headline */}
+
+ Own your SEO
+
+
+
+ Open source alternative to Semrush and Ahrefs
+
+
+ {/* Features */}
+
+ {[
+ "Keyword Research",
+ "Backlink Analysis",
+ "Competitor Insights",
+ "Site Audits",
+ "Rank Tracking and more (Coming soon)",
+ ].map((item) => (
+
+ —
+ {item}
+
+ ))}
+
+
+ {/* CTA */}
+
+
+
+ {/* Self-host — after video on mobile, before on desktop */}
+
+
+ Self-host via Docker or Cloudflare
+
+
+ 100% open source (MIT). Bring your own DataForSEO api key. Fork and vibe code custom features for your workflow.
+
+
+ View on GitHub
+ →
+
+
+
+ Don't want to self-host?{" "}
+
+ Try the managed version.
+
+
+
+
+ {/* Demo */}
+
+
+
+
+
+
+
+ Keyword research in OpenSEO
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/routes/_marketing/pricing.tsx b/web/src/routes/_marketing/pricing.tsx
new file mode 100644
index 0000000..e5f0a3a
--- /dev/null
+++ b/web/src/routes/_marketing/pricing.tsx
@@ -0,0 +1,155 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { buildPageSeo } from "@/lib/seo";
+
+export const Route = createFileRoute("/_marketing/pricing")({
+ head: () =>
+ buildPageSeo({
+ title: "Pricing",
+ description:
+ "OpenSEO is free to self-host. The managed service is $10/month and includes Usage Credits.",
+ path: "/pricing",
+ titleSuffix: "OpenSEO",
+ }),
+ component: Pricing,
+});
+
+function Pricing() {
+ return (
+ <>
+ {/* Headline */}
+
Pricing
+
+ {/* Managed */}
+
+ Managed
+
+ {/* Base Plan */}
+
+
+
Base Plan
+
$10/month
+
+
+ {[
+ "Access to OpenSEO's managed service",
+ "Includes $10.00 of Usage Credits each billing cycle",
+ "Monthly included credits reset each cycle",
+ ].map((item) => (
+
+
+ —
+
+ {item}
+
+ ))}
+
+
+
+ {/* Usage Credits — add-on, visually connected to Base Plan */}
+
+
+
Usage Credits
+
+ add-on
+
+
+
+ Credits are consumed as you use the app. We use powerful
+ providers for AI and SEO data. We bill based on usage of those
+ APIs and charge a small premium.
+
+
+ {[
+ "Purchase top-up credits if you use up your monthly credits",
+ "Top-up credits roll over and don't expire",
+ ].map((item) => (
+
+
+ —
+
+ {item}
+
+ ))}
+
+
+
+
+ Get Started{" "}→
+
+
+
+ {/* Self-hosted */}
+
+
+
Self-hosted
+
+
+ Deploy OpenSEO yourself via Docker or Cloudflare Workers. Bring your
+ own API keys and pay DataForSEO and other API providers directly.
+
+
+ View on GitHub
+ →
+
+
+
+ {/* FAQ */}
+
+ FAQ
+
+
+
+ What features use credits vs. what's unlimited?
+
+
+ Credits are consumed by features that query DataForSEO's API —
+ backlinks, keyword volume, competitor data, and site audits.
+ Your projects, settings, and any data already fetched don't cost
+ credits.
+
+
+
+
+ Do unused credits roll over?
+
+
+ Top-up credits roll over indefinitely. The Usage Credits
+ included with your Base Plan reset each billing cycle.
+
+
+
+
Can I cancel anytime?
+
+ Yes. Cancel from your billing portal at any time. Your access
+ continues through the end of the current billing period.{" "}
+
+
+
+
+ Do I need a subscription or just usage credits?
+
+
+ While top-up Usage Credits roll over and don't expire, you need
+ an active subscription in order to use OpenSEO.
+
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx
deleted file mode 100644
index 396cda8..0000000
--- a/web/src/routes/index.tsx
+++ /dev/null
@@ -1,221 +0,0 @@
-import { createFileRoute } from "@tanstack/react-router";
-import { useState } from "react";
-import { SiteFooter } from "@/components/site-footer";
-import { buildPageSeo } from "@/lib/seo";
-
-const homeTitle = "OpenSEO - Open Source SEO Platform";
-const homeDescription =
- "Own your SEO. OpenSEO helps teams manage keyword research, backlink analysis, competitor monitoring, and site audits without expensive monthly SEO software subscriptions.";
-
-export const Route = createFileRoute("/")({
- head: () =>
- buildPageSeo({
- title: homeTitle,
- description: homeDescription,
- path: "/",
- imageAlt: "OpenSEO keyword research dashboard preview",
- }),
- component: Home,
-});
-
-// ─── Shared ──────────────────────────────────────────────────────────
-
-function GitHubIcon({ size = 18 }: { size?: number }) {
- return (
-
-
-
- );
-}
-
-function useWaitlist() {
- const [email, setEmail] = useState("");
- const [status, setStatus] = useState<
- "idle" | "loading" | "success" | "error"
- >("idle");
- const [errorMessage, setErrorMessage] = useState("");
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setStatus("loading");
- setErrorMessage("");
- try {
- const res = await fetch("/api/subscribe", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email }),
- });
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- throw new Error(
- (data as { error?: string }).error || "Something went wrong",
- );
- }
- setStatus("success");
- setEmail("");
- } catch (err) {
- setStatus("error");
- setErrorMessage(
- err instanceof Error ? err.message : "Something went wrong",
- );
- }
- };
-
- return { email, setEmail, status, errorMessage, handleSubmit };
-}
-
-// ─── Page ────────────────────────────────────────────────────────────
-
-function Home() {
- const wl = useWaitlist();
-
- return (
-
-
- {/* Nav */}
-
- OpenSEO
-
-
- GitHub
-
-
-
- {/* Headline */}
-
- Own your SEO
-
-
-
- Open source alternative to Semrush and Ahrefs
-
-
- {/* Features */}
-
- {[
- "Keyword Research",
- "Backlink Analysis",
- "Competitor Insights",
- "Site Audits",
- "Rank Tracking and more (Coming soon)",
- ].map((item) => (
-
- —
- {item}
-
- ))}
-
-
- {/* Form */}
-
- {wl.status === "success" ? (
-
You're on the list.
- ) : (
-
- )}
-
- Get notified when the managed version is ready and when we add new
- features.
-
-
-
-
-
- Self-host today via Docker or Cloudflare
-
-
- Bring your own DataForSEO API key. Pay by usage, not per month. 100%
- open source (MIT).
-
-
- View on GitHub
- →
-
-
-
- If you don't want to self host or the DataForSEO minimum commitments
- are too high, sign up in the form above and we'll notify you when
- the managed version is released.
-
-
-
- {/* Demo */}
-
-
-
-
-
-
-
- Keyword research in OpenSEO
-
-
-
- {/* Footer */}
-
-
-
-
-
- );
-}
diff --git a/web/src/routes/pricing.tsx b/web/src/routes/pricing.tsx
deleted file mode 100644
index df4ee65..0000000
--- a/web/src/routes/pricing.tsx
+++ /dev/null
@@ -1,212 +0,0 @@
-import { createFileRoute, Link } from "@tanstack/react-router";
-import { buildPageSeo } from "@/lib/seo";
-
-export const Route = createFileRoute("/pricing")({
- head: () =>
- buildPageSeo({
- title: "Pricing",
- description:
- "OpenSEO is free to self-host. The managed service is $10/month and includes Usage Credits.",
- path: "/pricing",
- titleSuffix: "OpenSEO",
- }),
- component: Pricing,
-});
-
-function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
- return (
-
- {children}
-
- );
-}
-
-function Pricing() {
- return (
-
-
- {/* Nav */}
-
-
- OpenSEO
-
-
- GitHub
-
-
-
- {/* Headline */}
-
Pricing
-
- {/* Managed */}
-
- Managed
-
- {/* Base Plan */}
-
-
-
Base Plan
-
$10/month
-
-
- {[
- "Access to OpenSEO's managed service",
- "Includes $10.00 of Usage Credits each billing cycle",
- "Monthly included credits reset each cycle",
- ].map((item) => (
-
-
- —
-
- {item}
-
- ))}
-
-
-
- {/* Usage Credits — add-on, visually connected to Base Plan */}
-
-
-
Usage Credits
-
- add-on
-
-
-
- Credits are consumed as you use the app. We use powerful
- providers for AI and SEO data. We bill based on usage of those
- APIs and charge a small premium.
-
-
- {[
- "Purchase top-up credits if you use up your monthly credits",
- "Top-up credits roll over and don't expire",
- ].map((item) => (
-
-
- —
-
- {item}
-
- ))}
-
-
-
-
- Get Started{" "}→
-
-
-
- {/* Self-hosted */}
-
-
-
Self-hosted
-
-
- Deploy OpenSEO yourself via Docker or Cloudflare Workers. Bring your
- own API keys and pay DataForSEO and other API providers directly.
-
-
- View on GitHub
- →
-
-
-
- {/* FAQ */}
-
- FAQ
-
-
-
- What features use credits vs. what's unlimited?
-
-
- Credits are consumed by features that query DataForSEO's API —
- backlinks, keyword volume, competitor data, and site audits.
- Your projects, settings, and any data already fetched don't cost
- credits.
-
-
-
-
- Do unused credits roll over?
-
-
- Top-up credits roll over indefinitely. The Usage Credits
- included with your Base Plan reset each billing cycle.
-
-
-
-
Can I cancel anytime?
-
- Yes. Cancel from your billing portal at any time. Your access
- continues through the end of the current billing period.{" "}
-
-
-
-
- Do I need a subscription or just usage credits?
-
-
- While top-up Usage Credits roll over and don't expire, you need
- an active subscription in order to use OpenSEO.
-
-
-
-
-
- {/* Footer */}
-
-
-
- );
-}