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 ( +
+
+ + + +
+
+ ); +} + +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. +

+ ) : ( +
+
+ nl.setEmail(e.target.value)} + placeholder="you@example.com" + className="flex-1 min-w-0 h-10 px-3 text-sm border border-neutral-300 rounded-md bg-white placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-900 transition-all" + disabled={nl.status === "loading"} + /> + +
+ {nl.status === "error" && ( +

+ {nl.errorMessage} +

+ )} +
+ )} +
+
+ + {/* 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 */} + + + {/* CTA */} +
+ + Try now + +

+ No credit card required. +

+
+ +
+ {/* 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 */} - - - {/* 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.

- ) : ( -
-
- wl.setEmail(e.target.value)} - placeholder="you@example.com" - className="flex-1 min-w-0 h-10 px-3 text-sm border border-neutral-300 rounded-md bg-white placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-900 transition-all" - disabled={wl.status === "loading"} - /> - -
- {wl.status === "error" && ( -

{wl.errorMessage}

- )} -
- )} -

- 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 */} - - - {/* 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 */} -
-
- - GitHub - - - Discord - - Pricing - Privacy -
-
-
-
- ); -}