website: convert CTA to sign-up (#108)

* landing: convert main CTA to sign-up, add shared marketing layout

- Replace email waitlist form with "Try now" sign-up button as primary CTA
- Add shared _marketing layout route with consistent nav (Pricing, GitHub, Sign in)
- Move email form to bottom of homepage as "Stay in the loop" newsletter
- Update self-host card copy for live managed version
- Reorder self-host card below video on mobile via flex order
- Add /pricing to sitemap and SiteFooter

* change footer and add social card

* landing: move newsletter and footer to shared marketing layout

Move divider line above the newsletter form instead of between
the form and footer links.
This commit is contained in:
Ben Senescu 2026-04-09 15:13:23 -04:00 committed by GitHub
parent 937665ef95
commit 4137b386ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 504 additions and 470 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 147 KiB

View File

@ -12,7 +12,7 @@ const DIST_DIR = join(__dirname, "../dist/client");
const DEFAULT_SITE_URL = "https://openseo.so"; const DEFAULT_SITE_URL = "https://openseo.so";
const SITE_URL = (process.env.SITE_URL ?? DEFAULT_SITE_URL).replace(/\/+$/, ""); 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) { function toCanonicalUrl(path) {
if (path === "/") { if (path === "/") {

View File

@ -4,6 +4,7 @@ export function SiteFooter({ className }: { className?: string }) {
return ( return (
<div className={className}> <div className={className}>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<Link to="/" className="font-semibold">OpenSEO</Link>
<a <a
href="https://github.com/every-app/open-seo" href="https://github.com/every-app/open-seo"
target="_blank" target="_blank"
@ -18,6 +19,7 @@ export function SiteFooter({ className }: { className?: string }) {
> >
Discord Discord
</a> </a>
<Link to="/pricing">Pricing</Link>
<Link to="/privacy">Privacy</Link> <Link to="/privacy">Privacy</Link>
<Link to="/terms-and-conditions">Terms</Link> <Link to="/terms-and-conditions">Terms</Link>
</div> </div>

View File

@ -11,13 +11,14 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as TermsAndConditionsRouteImport } from './routes/terms-and-conditions' import { Route as TermsAndConditionsRouteImport } from './routes/terms-and-conditions'
import { Route as PrivacyRouteImport } from './routes/privacy' import { Route as PrivacyRouteImport } from './routes/privacy'
import { Route as PricingRouteImport } from './routes/pricing' import { Route as MarketingRouteImport } from './routes/_marketing'
import { Route as IndexRouteImport } from './routes/index'
import { Route as BlogsIndexRouteImport } from './routes/blogs/index' 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 JsScriptDotjsRouteImport } from './routes/js/script[.]js'
import { Route as BlogsSplatRouteImport } from './routes/blogs/$' import { Route as BlogsSplatRouteImport } from './routes/blogs/$'
import { Route as ApiSubscribeRouteImport } from './routes/api/subscribe' import { Route as ApiSubscribeRouteImport } from './routes/api/subscribe'
import { Route as ApiEventRouteImport } from './routes/api/event' import { Route as ApiEventRouteImport } from './routes/api/event'
import { Route as MarketingPricingRouteImport } from './routes/_marketing/pricing'
const TermsAndConditionsRoute = TermsAndConditionsRouteImport.update({ const TermsAndConditionsRoute = TermsAndConditionsRouteImport.update({
id: '/terms-and-conditions', id: '/terms-and-conditions',
@ -29,14 +30,8 @@ const PrivacyRoute = PrivacyRouteImport.update({
path: '/privacy', path: '/privacy',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const PricingRoute = PricingRouteImport.update({ const MarketingRoute = MarketingRouteImport.update({
id: '/pricing', id: '/_marketing',
path: '/pricing',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const BlogsIndexRoute = BlogsIndexRouteImport.update({ const BlogsIndexRoute = BlogsIndexRouteImport.update({
@ -44,6 +39,11 @@ const BlogsIndexRoute = BlogsIndexRouteImport.update({
path: '/blogs/', path: '/blogs/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const MarketingIndexRoute = MarketingIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => MarketingRoute,
} as any)
const JsScriptDotjsRoute = JsScriptDotjsRouteImport.update({ const JsScriptDotjsRoute = JsScriptDotjsRouteImport.update({
id: '/js/script.js', id: '/js/script.js',
path: '/js/script.js', path: '/js/script.js',
@ -64,12 +64,17 @@ const ApiEventRoute = ApiEventRouteImport.update({
path: '/api/event', path: '/api/event',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const MarketingPricingRoute = MarketingPricingRouteImport.update({
id: '/pricing',
path: '/pricing',
getParentRoute: () => MarketingRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof MarketingIndexRoute
'/pricing': typeof PricingRoute
'/privacy': typeof PrivacyRoute '/privacy': typeof PrivacyRoute
'/terms-and-conditions': typeof TermsAndConditionsRoute '/terms-and-conditions': typeof TermsAndConditionsRoute
'/pricing': typeof MarketingPricingRoute
'/api/event': typeof ApiEventRoute '/api/event': typeof ApiEventRoute
'/api/subscribe': typeof ApiSubscribeRoute '/api/subscribe': typeof ApiSubscribeRoute
'/blogs/$': typeof BlogsSplatRoute '/blogs/$': typeof BlogsSplatRoute
@ -77,35 +82,36 @@ export interface FileRoutesByFullPath {
'/blogs/': typeof BlogsIndexRoute '/blogs/': typeof BlogsIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute
'/pricing': typeof PricingRoute
'/privacy': typeof PrivacyRoute '/privacy': typeof PrivacyRoute
'/terms-and-conditions': typeof TermsAndConditionsRoute '/terms-and-conditions': typeof TermsAndConditionsRoute
'/pricing': typeof MarketingPricingRoute
'/api/event': typeof ApiEventRoute '/api/event': typeof ApiEventRoute
'/api/subscribe': typeof ApiSubscribeRoute '/api/subscribe': typeof ApiSubscribeRoute
'/blogs/$': typeof BlogsSplatRoute '/blogs/$': typeof BlogsSplatRoute
'/js/script.js': typeof JsScriptDotjsRoute '/js/script.js': typeof JsScriptDotjsRoute
'/': typeof MarketingIndexRoute
'/blogs': typeof BlogsIndexRoute '/blogs': typeof BlogsIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/_marketing': typeof MarketingRouteWithChildren
'/pricing': typeof PricingRoute
'/privacy': typeof PrivacyRoute '/privacy': typeof PrivacyRoute
'/terms-and-conditions': typeof TermsAndConditionsRoute '/terms-and-conditions': typeof TermsAndConditionsRoute
'/_marketing/pricing': typeof MarketingPricingRoute
'/api/event': typeof ApiEventRoute '/api/event': typeof ApiEventRoute
'/api/subscribe': typeof ApiSubscribeRoute '/api/subscribe': typeof ApiSubscribeRoute
'/blogs/$': typeof BlogsSplatRoute '/blogs/$': typeof BlogsSplatRoute
'/js/script.js': typeof JsScriptDotjsRoute '/js/script.js': typeof JsScriptDotjsRoute
'/_marketing/': typeof MarketingIndexRoute
'/blogs/': typeof BlogsIndexRoute '/blogs/': typeof BlogsIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| '/' | '/'
| '/pricing'
| '/privacy' | '/privacy'
| '/terms-and-conditions' | '/terms-and-conditions'
| '/pricing'
| '/api/event' | '/api/event'
| '/api/subscribe' | '/api/subscribe'
| '/blogs/$' | '/blogs/$'
@ -113,31 +119,31 @@ export interface FileRouteTypes {
| '/blogs/' | '/blogs/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/'
| '/pricing'
| '/privacy' | '/privacy'
| '/terms-and-conditions' | '/terms-and-conditions'
| '/pricing'
| '/api/event' | '/api/event'
| '/api/subscribe' | '/api/subscribe'
| '/blogs/$' | '/blogs/$'
| '/js/script.js' | '/js/script.js'
| '/'
| '/blogs' | '/blogs'
id: id:
| '__root__' | '__root__'
| '/' | '/_marketing'
| '/pricing'
| '/privacy' | '/privacy'
| '/terms-and-conditions' | '/terms-and-conditions'
| '/_marketing/pricing'
| '/api/event' | '/api/event'
| '/api/subscribe' | '/api/subscribe'
| '/blogs/$' | '/blogs/$'
| '/js/script.js' | '/js/script.js'
| '/_marketing/'
| '/blogs/' | '/blogs/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute MarketingRoute: typeof MarketingRouteWithChildren
PricingRoute: typeof PricingRoute
PrivacyRoute: typeof PrivacyRoute PrivacyRoute: typeof PrivacyRoute
TermsAndConditionsRoute: typeof TermsAndConditionsRoute TermsAndConditionsRoute: typeof TermsAndConditionsRoute
ApiEventRoute: typeof ApiEventRoute ApiEventRoute: typeof ApiEventRoute
@ -163,18 +169,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PrivacyRouteImport preLoaderRoute: typeof PrivacyRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/pricing': { '/_marketing': {
id: '/pricing' id: '/_marketing'
path: '/pricing' path: ''
fullPath: '/pricing'
preLoaderRoute: typeof PricingRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/' fullPath: '/'
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof MarketingRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/blogs/': { '/blogs/': {
@ -184,6 +183,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof BlogsIndexRouteImport preLoaderRoute: typeof BlogsIndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/_marketing/': {
id: '/_marketing/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof MarketingIndexRouteImport
parentRoute: typeof MarketingRoute
}
'/js/script.js': { '/js/script.js': {
id: '/js/script.js' id: '/js/script.js'
path: '/js/script.js' path: '/js/script.js'
@ -212,12 +218,32 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiEventRouteImport preLoaderRoute: typeof ApiEventRouteImport
parentRoute: typeof rootRouteImport 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 = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, MarketingRoute: MarketingRouteWithChildren,
PricingRoute: PricingRoute,
PrivacyRoute: PrivacyRoute, PrivacyRoute: PrivacyRoute,
TermsAndConditionsRoute: TermsAndConditionsRoute, TermsAndConditionsRoute: TermsAndConditionsRoute,
ApiEventRoute: ApiEventRoute, ApiEventRoute: ApiEventRoute,

View File

@ -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 (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}
export const Route = createFileRoute("/_marketing")({
component: MarketingLayout,
});
function MarketingLayout() {
return (
<main
className="bg-white text-neutral-900 min-h-screen"
style={{
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
}}
>
<div className="max-w-3xl mx-auto px-6 py-16 md:py-24">
<nav className="flex items-center justify-between mb-20">
<Link
to="/"
className="text-sm font-semibold hover:opacity-80 transition-opacity"
>
OpenSEO
</Link>
<div className="flex items-center gap-5">
<Link
to="/pricing"
className="text-sm text-neutral-600 hover:text-neutral-900 transition-colors"
>
Pricing
</Link>
<a
href="https://github.com/every-app/open-seo"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-neutral-600 hover:text-neutral-900 transition-colors"
>
<GitHubIcon size={16} />
<span>GitHub</span>
</a>
<a
href="https://app.openseo.so/sign-in"
className="h-8 px-3.5 text-sm font-medium border border-neutral-300 text-neutral-900 rounded-md hover:border-neutral-900 transition-colors inline-flex items-center"
>
Sign in
</a>
</div>
</nav>
<Outlet />
<MarketingFooter />
</div>
</main>
);
}
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 */}
<div className="mt-12 pt-8 border-t border-neutral-200">
<p className="text-sm font-semibold text-neutral-900">
Stay in the loop
</p>
<p className="text-sm text-neutral-600 mt-1 leading-relaxed">
Product updates, new features, and the occasional
behind-the-scenes.
</p>
<div className="mt-3">
{nl.status === "success" ? (
<p className="text-sm text-neutral-900">
You&apos;re on the list.
</p>
) : (
<form onSubmit={nl.handleSubmit} autoComplete="on">
<div className="flex gap-2">
<input
id="newsletter-email"
name="email"
type="email"
autoComplete="email"
required
value={nl.email}
onChange={(e) => 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"}
/>
<button
type="submit"
disabled={nl.status === "loading"}
className="h-10 px-5 text-sm font-medium bg-neutral-900 text-white rounded-md hover:bg-neutral-800 transition-colors disabled:opacity-50 shrink-0"
>
{nl.status === "loading" ? "..." : "Subscribe"}
</button>
</div>
{nl.status === "error" && (
<p className="text-red-600 text-xs mt-2">
{nl.errorMessage}
</p>
)}
</form>
)}
</div>
</div>
{/* Footer */}
<div className="mt-8">
<SiteFooter className="text-xs text-neutral-600 [&_a]:hover:text-neutral-900 [&_a]:transition-colors" />
</div>
</>
);
}

View File

@ -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 */}
<h1 className="text-3xl font-bold tracking-tight leading-tight">
Own your SEO
</h1>
<p className="text-neutral-700 mt-4 leading-relaxed">
Open source alternative to Semrush and Ahrefs
</p>
{/* Features */}
<ul className="mt-5 space-y-3">
{[
"Keyword Research",
"Backlink Analysis",
"Competitor Insights",
"Site Audits",
"Rank Tracking and more (Coming soon)",
].map((item) => (
<li key={item} className="flex gap-2.5 text-sm text-neutral-800">
<span className="text-neutral-500 mt-[2px]">&mdash;</span>
{item}
</li>
))}
</ul>
{/* CTA */}
<div className="mt-6">
<a
href="https://app.openseo.so/sign-up"
className="inline-flex items-center justify-center h-10 px-5 text-sm font-medium bg-neutral-900 text-white rounded-md hover:bg-neutral-800 transition-colors"
>
Try now
</a>
<p className="text-xs text-neutral-500 mt-3">
No credit card required.
</p>
</div>
<div className="mt-8 flex flex-col gap-8">
{/* Self-host — after video on mobile, before on desktop */}
<div className="order-2 md:order-1 rounded-lg border border-neutral-200 bg-neutral-50 px-5 py-5">
<p className="text-sm font-semibold text-neutral-900">
Self-host via Docker or Cloudflare
</p>
<p className="text-sm text-neutral-600 mt-2 leading-relaxed">
100% open source (MIT). Bring your own DataForSEO api key. Fork and vibe code custom features for your workflow.
</p>
<a
href="https://github.com/every-app/open-seo"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 mt-3 text-sm font-medium text-neutral-900 hover:text-neutral-700 transition-colors"
>
View on GitHub
<span aria-hidden="true">&rarr;</span>
</a>
<hr className="mt-4" />
<p className="text-sm text-neutral-600 mt-2 leading-relaxed">
Don&apos;t want to self-host?{" "}
<a
href="https://app.openseo.so/sign-up"
className="font-medium text-neutral-900 hover:text-neutral-700 transition-colors"
>
Try the managed version.
</a>
</p>
</div>
{/* Demo */}
<div className="order-1 md:order-2">
<video
className="w-full rounded-md border border-neutral-200"
width={1280}
height={808}
poster="/demo-poster.webp"
autoPlay
muted
loop
playsInline
preload="none"
aria-label="OpenSEO product demo"
>
<source src="/demo.webm" type="video/webm" />
<source src="/demo.mp4" type="video/mp4" />
<img
src="/demo-poster.webp"
alt="OpenSEO product demo"
width={1280}
height={808}
className="w-full rounded-md border border-neutral-200"
loading="lazy"
decoding="async"
/>
</video>
<p className="text-[11px] text-neutral-600 mt-2">
Keyword research in OpenSEO
</p>
</div>
</div>
</>
);
}

View File

@ -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 */}
<h1 className="text-3xl font-bold tracking-tight">Pricing</h1>
{/* Managed */}
<section className="mt-12">
<h2 className="text-xl font-semibold">Managed</h2>
<div className="mt-6 rounded-lg border border-neutral-200 overflow-hidden">
{/* Base Plan */}
<div className="px-5 py-5">
<div className="flex items-baseline justify-between gap-4">
<p className="font-semibold">Base Plan</p>
<p className="text-lg font-semibold tabular-nums">$10/month</p>
</div>
<ul className="mt-3 space-y-2">
{[
"Access to OpenSEO's managed service",
"Includes $10.00 of Usage Credits each billing cycle",
"Monthly included credits reset each cycle",
].map((item) => (
<li
key={item}
className="flex gap-2.5 text-sm text-neutral-700"
>
<span className="text-neutral-400 mt-[2px] shrink-0">
&mdash;
</span>
{item}
</li>
))}
</ul>
</div>
{/* Usage Credits — add-on, visually connected to Base Plan */}
<div className="border-t border-dashed border-neutral-200 px-5 py-5">
<div className="flex items-baseline gap-3">
<p className="text-sm font-semibold">Usage Credits</p>
<span className="text-xs text-neutral-400 font-medium">
add-on
</span>
</div>
<p className="mt-2 text-sm text-neutral-600 leading-relaxed">
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.
</p>
<ul className="mt-3 space-y-2">
{[
"Purchase top-up credits if you use up your monthly credits",
"Top-up credits roll over and don't expire",
].map((item) => (
<li
key={item}
className="flex gap-2.5 text-sm text-neutral-700"
>
<span className="text-neutral-400 mt-[2px] shrink-0">
&mdash;
</span>
{item}
</li>
))}
</ul>
</div>
</div>
<a
href="https://app.openseo.so/sign-up"
className="inline-flex items-center justify-center mt-4 rounded-lg bg-neutral-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-neutral-800 transition-colors"
>
Get Started{" "}<span aria-hidden="true" className="ml-1.5">&rarr;</span>
</a>
</section>
{/* Self-hosted */}
<section className="mt-12">
<div className="flex items-baseline justify-between gap-4">
<h2 className="text-xl font-semibold">Self-hosted</h2>
</div>
<p className="mt-2 text-sm text-neutral-600 leading-relaxed">
Deploy OpenSEO yourself via Docker or Cloudflare Workers. Bring your
own API keys and pay DataForSEO and other API providers directly.
</p>
<a
href="https://github.com/every-app/open-seo"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 mt-3 text-sm font-medium text-neutral-900 hover:text-neutral-600 transition-colors"
>
View on GitHub
<span aria-hidden="true">&rarr;</span>
</a>
</section>
{/* FAQ */}
<section className="mt-12">
<h2 className="text-xl font-semibold">FAQ</h2>
<dl className="mt-4 space-y-6">
<div>
<dt className="text-sm font-medium">
What features use credits vs. what's unlimited?
</dt>
<dd className="mt-1 text-sm text-neutral-600 leading-relaxed">
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.
</dd>
</div>
<div>
<dt className="text-sm font-medium">
Do unused credits roll over?
</dt>
<dd className="mt-1 text-sm text-neutral-600 leading-relaxed">
Top-up credits roll over indefinitely. The Usage Credits
included with your Base Plan reset each billing cycle.
</dd>
</div>
<div>
<dt className="text-sm font-medium">Can I cancel anytime?</dt>
<dd className="mt-1 text-sm text-neutral-600 leading-relaxed">
Yes. Cancel from your billing portal at any time. Your access
continues through the end of the current billing period.{" "}
</dd>
</div>
<div>
<dt className="text-sm font-medium">
Do I need a subscription or just usage credits?
</dt>
<dd className="mt-1 text-sm text-neutral-600 leading-relaxed">
While top-up Usage Credits roll over and don't expire, you need
an active subscription in order to use OpenSEO.
</dd>
</div>
</dl>
</section>
</>
);
}

View File

@ -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 (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}
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 (
<main
className="bg-white text-neutral-900 min-h-screen"
style={{
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
}}
>
<div className="max-w-3xl mx-auto px-6 py-16 md:py-24">
{/* Nav */}
<nav className="flex items-center justify-between mb-20">
<span className="text-sm font-semibold">OpenSEO</span>
<a
href="https://github.com/every-app/open-seo"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm font-medium text-neutral-700 hover:text-neutral-900 transition-colors"
aria-label="GitHub"
>
<GitHubIcon size={16} />
<span>GitHub</span>
</a>
</nav>
{/* Headline */}
<h1 className="text-3xl font-bold tracking-tight leading-tight">
Own your SEO
</h1>
<p className="text-neutral-700 mt-4 leading-relaxed">
Open source alternative to Semrush and Ahrefs
</p>
{/* Features */}
<ul className="mt-5 space-y-3">
{[
"Keyword Research",
"Backlink Analysis",
"Competitor Insights",
"Site Audits",
"Rank Tracking and more (Coming soon)",
].map((item) => (
<li key={item} className="flex gap-2.5 text-sm text-neutral-800">
<span className="text-neutral-500 mt-[2px]">&mdash;</span>
{item}
</li>
))}
</ul>
{/* Form */}
<div className="mt-6">
{wl.status === "success" ? (
<p className="text-sm text-neutral-900">You&apos;re on the list.</p>
) : (
<form onSubmit={wl.handleSubmit} autoComplete="on">
<div className="flex gap-2">
<input
id="waitlist-email"
name="email"
type="email"
autoComplete="email"
required
value={wl.email}
onChange={(e) => 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"}
/>
<button
type="submit"
disabled={wl.status === "loading"}
className="h-10 px-5 text-sm font-medium bg-neutral-900 text-white rounded-md hover:bg-neutral-800 transition-colors disabled:opacity-50 shrink-0"
>
{wl.status === "loading" ? "..." : "Notify me"}
</button>
</div>
{wl.status === "error" && (
<p className="text-red-600 text-xs mt-2">{wl.errorMessage}</p>
)}
</form>
)}
<p className="text-xs text-neutral-600 mt-3">
Get notified when the managed version is ready and when we add new
features.
</p>
</div>
<div className="mt-8 rounded-lg border border-neutral-200 bg-neutral-50 px-5 py-5">
<p className="text-sm font-semibold text-neutral-900">
Self-host today via Docker or Cloudflare
</p>
<p className="text-sm text-neutral-600 mt-2 leading-relaxed">
Bring your own DataForSEO API key. Pay by usage, not per month. 100%
open source (MIT).
</p>
<a
href="https://github.com/every-app/open-seo"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 mt-3 text-sm font-medium text-neutral-900 hover:text-neutral-700 transition-colors"
>
View on GitHub
<span aria-hidden="true">&rarr;</span>
</a>
<hr className="mt-4" />
<p className="text-sm text-neutral-600 mt-2 leading-relaxed">
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.
</p>
</div>
{/* Demo */}
<div className="mt-8">
<video
className="w-full rounded-md border border-neutral-200"
width={1280}
height={808}
poster="/demo-poster.webp"
autoPlay
muted
loop
playsInline
preload="none"
aria-label="OpenSEO product demo"
>
<source src="/demo.webm" type="video/webm" />
<source src="/demo.mp4" type="video/mp4" />
<img
src="/demo-poster.webp"
alt="OpenSEO product demo"
width={1280}
height={808}
className="w-full rounded-md border border-neutral-200"
loading="lazy"
decoding="async"
/>
</video>
<p className="text-[11px] text-neutral-600 mt-2">
Keyword research in OpenSEO
</p>
</div>
{/* Footer */}
<div className="mt-8 pt-8 border-t border-neutral-200">
<SiteFooter className="text-xs text-neutral-600 [&_a]:hover:text-neutral-900 [&_a]:transition-colors" />
</div>
</div>
</main>
);
}

View File

@ -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 (
<Link
to={to}
className="text-sm text-neutral-600 hover:text-neutral-900 transition-colors"
>
{children}
</Link>
);
}
function Pricing() {
return (
<main
className="bg-white text-neutral-900 min-h-screen"
style={{
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
}}
>
<div className="max-w-3xl mx-auto px-6 py-16 md:py-24">
{/* Nav */}
<nav className="flex items-center justify-between mb-20">
<Link
to="/"
className="text-sm font-semibold hover:opacity-80 transition-opacity"
>
OpenSEO
</Link>
<a
href="https://github.com/every-app/open-seo"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-neutral-700 hover:text-neutral-900 transition-colors"
>
GitHub
</a>
</nav>
{/* Headline */}
<h1 className="text-3xl font-bold tracking-tight">Pricing</h1>
{/* Managed */}
<section className="mt-12">
<h2 className="text-xl font-semibold">Managed</h2>
<div className="mt-6 rounded-lg border border-neutral-200 overflow-hidden">
{/* Base Plan */}
<div className="px-5 py-5">
<div className="flex items-baseline justify-between gap-4">
<p className="font-semibold">Base Plan</p>
<p className="text-lg font-semibold tabular-nums">$10/month</p>
</div>
<ul className="mt-3 space-y-2">
{[
"Access to OpenSEO's managed service",
"Includes $10.00 of Usage Credits each billing cycle",
"Monthly included credits reset each cycle",
].map((item) => (
<li
key={item}
className="flex gap-2.5 text-sm text-neutral-700"
>
<span className="text-neutral-400 mt-[2px] shrink-0">
&mdash;
</span>
{item}
</li>
))}
</ul>
</div>
{/* Usage Credits — add-on, visually connected to Base Plan */}
<div className="border-t border-dashed border-neutral-200 px-5 py-5">
<div className="flex items-baseline gap-3">
<p className="text-sm font-semibold">Usage Credits</p>
<span className="text-xs text-neutral-400 font-medium">
add-on
</span>
</div>
<p className="mt-2 text-sm text-neutral-600 leading-relaxed">
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.
</p>
<ul className="mt-3 space-y-2">
{[
"Purchase top-up credits if you use up your monthly credits",
"Top-up credits roll over and don't expire",
].map((item) => (
<li
key={item}
className="flex gap-2.5 text-sm text-neutral-700"
>
<span className="text-neutral-400 mt-[2px] shrink-0">
&mdash;
</span>
{item}
</li>
))}
</ul>
</div>
</div>
<a
href="https://app.openseo.so/sign-up"
className="inline-flex items-center justify-center mt-4 rounded-lg bg-neutral-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-neutral-800 transition-colors"
>
Get Started{" "}<span aria-hidden="true" className="ml-1.5">&rarr;</span>
</a>
</section>
{/* Self-hosted */}
<section className="mt-12">
<div className="flex items-baseline justify-between gap-4">
<h2 className="text-xl font-semibold">Self-hosted</h2>
</div>
<p className="mt-2 text-sm text-neutral-600 leading-relaxed">
Deploy OpenSEO yourself via Docker or Cloudflare Workers. Bring your
own API keys and pay DataForSEO and other API providers directly.
</p>
<a
href="https://github.com/every-app/open-seo"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 mt-3 text-sm font-medium text-neutral-900 hover:text-neutral-600 transition-colors"
>
View on GitHub
<span aria-hidden="true">&rarr;</span>
</a>
</section>
{/* FAQ */}
<section className="mt-12">
<h2 className="text-xl font-semibold">FAQ</h2>
<dl className="mt-4 space-y-6">
<div>
<dt className="text-sm font-medium">
What features use credits vs. what's unlimited?
</dt>
<dd className="mt-1 text-sm text-neutral-600 leading-relaxed">
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.
</dd>
</div>
<div>
<dt className="text-sm font-medium">
Do unused credits roll over?
</dt>
<dd className="mt-1 text-sm text-neutral-600 leading-relaxed">
Top-up credits roll over indefinitely. The Usage Credits
included with your Base Plan reset each billing cycle.
</dd>
</div>
<div>
<dt className="text-sm font-medium">Can I cancel anytime?</dt>
<dd className="mt-1 text-sm text-neutral-600 leading-relaxed">
Yes. Cancel from your billing portal at any time. Your access
continues through the end of the current billing period.{" "}
</dd>
</div>
<div>
<dt className="text-sm font-medium">
Do I need a subscription or just usage credits?
</dt>
<dd className="mt-1 text-sm text-neutral-600 leading-relaxed">
While top-up Usage Credits roll over and don't expire, you need
an active subscription in order to use OpenSEO.
</dd>
</div>
</dl>
</section>
{/* Footer */}
<div className="mt-16 pt-8 border-t border-neutral-200">
<div className="flex items-center gap-6 text-xs text-neutral-600 [&_a]:hover:text-neutral-900 [&_a]:transition-colors">
<a
href="https://github.com/every-app/open-seo"
target="_blank"
rel="noopener noreferrer"
>
GitHub
</a>
<a
href="https://discord.gg/c9uGs3cFXr"
target="_blank"
rel="noopener noreferrer"
>
Discord
</a>
<NavLink to="/pricing">Pricing</NavLink>
<NavLink to="/privacy">Privacy</NavLink>
</div>
</div>
</div>
</main>
);
}