Pricing page redesign: usage estimator + flat layout (#368)

This commit is contained in:
Ben Senescu 2026-07-19 00:51:51 -04:00 committed by GitHub
parent 072fbecae3
commit cd550729a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 684 additions and 149 deletions

View File

@ -46,6 +46,7 @@
"test:e2e:keywords": "playwright test e2e/keyword-research-navigation.spec.ts",
"billing:backlinks": "tsx scripts/backlinks-cost-profile.ts",
"billing:brand-lookup": "tsx scripts/brand-lookup-cost-profile.ts",
"billing:usage": "tsx scripts/dataforseo-account-usage.ts",
"cleanup:default-projects:d1": "tsx scripts/d1-default-project-cleanup.ts",
"seed:rank-tracking": "tsx scripts/seed-rank-tracking.ts",
"ci:check": "prettier --check . && knip && tsc --noEmit && tsc --noEmit -p badseo/tsconfig.json && oxlint . --type-aware"

View File

@ -0,0 +1,116 @@
import process from "node:process";
import type { AppendixStatisticsRatesDataInfo } from "dataforseo-client";
import { fetchUserData } from "@/server/lib/dataforseo/appendix";
import { loadLocalEnv, parseArgs } from "./cli-utils";
loadLocalEnv();
const args = parseArgs(process.argv.slice(2));
await main();
/**
* Inspect real DataForSEO account spend via the FREE GET
* /v3/appendix/user_data endpoint. Unlike the other billing:* scripts, this
* makes NO billable calls it just reads the numbers DataForSEO already
* tracks: lifetime deposit, remaining balance, and per-function spend for the
* rolling day / minute window.
*
* Usage: pnpm billing:usage [--json=true]
*/
async function main() {
if (!process.env.DATAFORSEO_API_KEY) {
printUsageAndExit("Missing DATAFORSEO_API_KEY.");
}
const account = await fetchUserData();
if (!account) {
console.error(
"DataForSEO returned no account data (empty result). Check the API key.",
);
process.exit(1);
}
if (args.json === "true") {
console.log(JSON.stringify(account, null, 2));
return;
}
const money = account.money;
console.log("DataForSEO account usage");
console.log("========================");
console.log(`Login: ${account.login ?? "(unknown)"}`);
if (account.timezone) console.log(`Timezone: ${account.timezone}`);
console.log(`Deposited total: ${formatUsd(money?.total)}`);
console.log(`Balance left: ${formatUsd(money?.balance)}`);
printFunctionTable("Spend by function — rolling DAY", money?.statistics?.day);
printFunctionTable(
"Spend by function — rolling MINUTE",
money?.statistics?.minute,
);
}
// DataForSEO groups spend under `total_<function>` keys on each statistics
// window. Field names mirror the SDK's AppendixStatisticsRatesDataInfo.
const FUNCTION_TOTALS: ReadonlyArray<{ label: string; key: string }> = [
{ label: "serp", key: "total_serp" },
{ label: "keywords_data", key: "total_keywords_data" },
{ label: "dataforseo_labs", key: "total_dataforseo_labs" },
{ label: "backlinks", key: "total_backlinks" },
{ label: "on_page", key: "total_on_page" },
{ label: "business_data", key: "total_business_data" },
{ label: "domain_analytics", key: "total_domain_analytics" },
{ label: "merchant", key: "total_merchant" },
{ label: "app_data", key: "total_app_data" },
{ label: "content_analysis", key: "total_content_analysis" },
{ label: "content_generation", key: "total_content_generation" },
{ label: "appendix", key: "total_appendix" },
];
function printFunctionTable(
heading: string,
stats: AppendixStatisticsRatesDataInfo | undefined,
) {
console.log("");
console.log(heading);
console.log("-".repeat(heading.length));
if (!stats) {
console.log("(no data)");
return;
}
if (stats.value) console.log(`Window: ${stats.value}`);
const rows = FUNCTION_TOTALS.map(({ label, key }) => ({
function: label,
spend: readNumber(stats[key]),
})).filter((row) => row.spend > 0);
if (rows.length === 0) {
console.log("(no spend recorded in this window)");
} else {
for (const row of rows) {
console.log(` ${row.function.padEnd(20)} ${formatUsd(row.spend)}`);
}
}
// Prefer the API's own grand total; fall back to the summed rows.
const total =
readNumber(stats.total) || rows.reduce((sum, row) => sum + row.spend, 0);
console.log(` ${"TOTAL".padEnd(20)} ${formatUsd(total)}`);
}
function readNumber(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function formatUsd(value: number | undefined): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "$0.00";
return `$${value.toFixed(2)}`;
}
function printUsageAndExit(message: string): never {
console.error(message);
console.error("Usage: pnpm billing:usage [--json=true]");
process.exit(1);
}

View File

@ -8,6 +8,7 @@ import {
ONBOARDING_LAST_STEP,
type OnboardingAnswers,
SOURCE_OPTIONS,
SOURCE_OPTIONS_HIDDEN_ON_MOBILE,
WORK_FOR_OPTIONS,
} from "@/client/features/onboarding/onboardingModel";
import { SearchConsoleOnboardingStep } from "@/client/features/onboarding/SearchConsoleOnboardingStep";
@ -121,6 +122,7 @@ export function PostSignupOnboarding({
onToggle={(source) => updateAnswers({ source })}
otherValue={answers.sourceOther}
onOtherChange={(sourceOther) => updateAnswers({ sourceOther })}
hiddenOnMobile={[...SOURCE_OPTIONS_HIDDEN_ON_MOBILE]}
/>
) : (
<SearchConsoleOnboardingStep />
@ -183,6 +185,7 @@ function OnboardingChoiceGroup({
multiple = false,
maxSelections,
followUp,
hiddenOnMobile,
}: {
title: string;
description?: string;
@ -193,6 +196,7 @@ function OnboardingChoiceGroup({
onOtherChange: (value: string) => void;
multiple?: boolean;
maxSelections?: number;
hiddenOnMobile?: string[];
followUp?: {
showForValue: string;
label: string;
@ -222,12 +226,14 @@ function OnboardingChoiceGroup({
const disabled = atLimit && !selected;
const showFollowUpHere =
showFollowUp && followUp?.showForValue === option;
// Selected options stay visible so a restored answer never vanishes.
const mobileHidden = hiddenOnMobile?.includes(option) && !selected;
return (
<Fragment key={option}>
<button
type="button"
className={`flex min-h-11 items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
className={`${mobileHidden ? "hidden sm:flex" : "flex"} min-h-11 items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
selected
? "border-base-content bg-base-200 text-base-content"
: disabled

View File

@ -44,7 +44,7 @@ export function SearchConsoleOnboardingStep() {
{project ? <GscConnect projectId={project.id} /> : <Checking />}
<p className="text-xs leading-relaxed text-base-content/55">
<p className="hidden sm:block text-xs leading-relaxed text-base-content/55">
For now, Search Console data flows through the OpenSEO MCP. We're
building it into the OpenSEO app soon too.
</p>
@ -89,8 +89,12 @@ function DefaultMarketPicker({
return (
<div className="space-y-2">
<ProjectMarketFields value={market} onChange={handleChange} />
<p className="text-xs leading-relaxed text-base-content/55">
<ProjectMarketFields
value={market}
onChange={handleChange}
hideLanguageOnMobile
/>
<p className="hidden sm:block text-xs leading-relaxed text-base-content/55">
We'll use this country and language for keyword, SERP, and domain data
unless you pick a different one. You can change it in project settings.
</p>

View File

@ -37,12 +37,19 @@ export const SOURCE_OPTIONS = [
"Reddit",
"X / Twitter",
"GitHub",
"ChatGPT",
"Claude",
"AI (Claude, ChatGPT, etc)",
"Friend or colleague",
"Other",
] as const;
// Keep the mobile list short: these still count as known options, they just
// aren't shown on small screens.
export const SOURCE_OPTIONS_HIDDEN_ON_MOBILE = [
"Reddit",
"X / Twitter",
"AI (Claude, ChatGPT, etc)",
] as const;
/** In-progress form state. Step is tracked separately in the URL. */
export type OnboardingAnswers = {
selectedInterests: string[];

View File

@ -14,9 +14,11 @@ import type { ProjectMarket } from "@/client/features/projects/types";
export function ProjectMarketFields({
value,
onChange,
hideLanguageOnMobile = false,
}: {
value: ProjectMarket;
onChange: (market: ProjectMarket) => void;
hideLanguageOnMobile?: boolean;
}) {
const languageOptions = getLanguageOptions(value.locationCode);
@ -34,7 +36,9 @@ export function ProjectMarketFields({
}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<label
className={`${hideLanguageOnMobile ? "hidden sm:flex" : "flex"} flex-col gap-1.5 text-sm`}
>
<span className="font-medium">Language</span>
<select
value={value.languageCode}

View File

@ -22,7 +22,7 @@ const SUPPORT_EMAIL = "ben@openseo.so";
const PLAN_FEATURES = [
"Keyword research, backlinks, rank tracking, and site audits",
"MCP server and agent skills for Claude, Cursor, and ChatGPT",
"Search Console integration that never uses credits",
"Google Search Console Integration",
"Includes $10.00 of Usage Credits each month",
];
@ -251,6 +251,21 @@ function SubscribePage() {
{item}
</li>
))}
{/* Sub-bullet of the Usage Credits line above. */}
<li className="-mt-1 pl-6 text-xs">
<a
className="text-base-content/60 underline decoration-base-content/40 decoration-dotted underline-offset-4 transition-colors hover:text-base-content"
href="https://openseo.so/pricing"
target="_blank"
rel="noreferrer"
onClick={() =>
captureClientEvent("billing:pricing_estimator_click")
}
>
How far do usage credits go?{" "}
<span aria-hidden="true">&#8599;</span>
</a>
</li>
</ul>
{error ? <p className="text-sm text-error">{error}</p> : null}
@ -278,11 +293,7 @@ function SubscribePage() {
<div className="text-center space-y-2">
<p className="text-sm text-base-content/60">
Questions?{" "}
<a className="link" href={`mailto:${SUPPORT_EMAIL}`}>
Email {SUPPORT_EMAIL}
</a>
.
Questions? Email {SUPPORT_EMAIL}.
</p>
{isUpgradeFlow ? (
<button

View File

@ -0,0 +1,30 @@
import type { AppendixUserDataResultInfo } from "dataforseo-client";
import { appendixApi } from "@/server/lib/dataforseo/core";
import { assertOk } from "@/server/lib/dataforseo/envelope";
/**
* Reads account spend + balance from DataForSEO's free GET
* /v3/appendix/user_data. This is the standard, non-billable way to inspect
* cost unlike the other billing scripts, it does NOT make a live billable
* call to observe spend, so there is nothing to meter and it is deliberately
* NOT wired through meterDataforseoCall / client.ts.
*
* The result carries `money.total` (lifetime deposited), `money.balance`
* (remaining), and `money.statistics.day` / `.minute` spend grouped by
* function (serp, keywords_data, backlinks, dataforseo_labs, on_page,
* business_data, ) for the rolling day / minute window.
*
* SDK types are loose (every field optional + index signatures), so callers
* must optional-chain into `.money.statistics.day`; it can be undefined.
*/
export async function fetchUserData(): Promise<
AppendixUserDataResultInfo | undefined
> {
const response = await appendixApi().userData();
// Validates top-level + task status; the call is free so there is no billing
// envelope to build.
const task = assertOk(response);
return task.result?.[0];
}

View File

@ -1,5 +1,6 @@
import {
AiOptimizationApi,
AppendixApi,
BacklinksApi,
BusinessDataApi,
DataforseoLabsApi,
@ -126,6 +127,9 @@ export const keywordsDataApi = () => new KeywordsDataApi(API_BASE, http());
export const serpApi = () => new SerpApi(API_BASE, http());
export const businessDataApi = () => new BusinessDataApi(API_BASE, http());
export const onPageApi = () => new OnPageApi(API_BASE, http());
// Account/appendix data (spend, balance, rates). userData() is FREE ($0) and
// read-only — do NOT wire it through metering.
export const appendixApi = () => new AppendixApi(API_BASE, http());
export const backlinksApi = (classify?: DataforseoErrorClassifier) =>
new BacklinksApi(API_BASE, http(classify));
export const aiOptimizationApi = (classify?: DataforseoErrorClassifier) =>

View File

@ -360,11 +360,29 @@
gap: 12px;
margin-top: 32px;
}
.itc-hero-cta-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.itc-hero-cta-note {
margin: 0;
color: var(--ink-subtle);
font-size: 13px;
line-height: 1.4;
}
@media (max-width: 479px) {
.itc-hero-ctas {
flex-direction: column;
align-items: stretch;
}
.itc-hero-cta-group {
align-items: stretch;
}
.itc-hero-cta-note {
text-align: center;
}
}
/* ── Testimonial band (colors driven by --q-* palette vars) ── */

View File

@ -216,7 +216,10 @@ function Hero() {
</span>
</p>
<div className="itc-hero-ctas">
<ArrowCta size="lg" />
<div className="itc-hero-cta-group">
<ArrowCta size="lg" />
<p className="itc-hero-cta-note">No credit card required</p>
</div>
</div>
</Container>
</section>

View File

@ -1,175 +1,450 @@
import { createFileRoute } from "@tanstack/react-router";
import { MoneyBackGuarantee } from "@/components/money-back-guarantee";
import { useMemo, useState } from "react";
import { buildPageSeo } from "@/lib/seo";
export const Route = createFileRoute("/_marketing/pricing")({
head: () =>
buildPageSeo({
title: "Pricing",
title: "Pricing estimator",
description:
"OpenSEO is free to self-host. The managed service is $10/month with a 30-day money-back guarantee.",
"Start at $10/mo. The $10 includes $10 of usage, enough for most people. Move the sliders to estimate what your month would cost.",
path: "/pricing",
titleSuffix: "OpenSEO",
}),
component: Pricing,
});
/* ------------------------------------------------------------------ *
* COST MODEL edit everything pricing-related here.
* Verified against the app call paths and live DataForSEO prices (Jul 2026).
* Keep the math consistent with src/shared/billing.ts:
* billedUsd = roundTo5Decimals(rawDataForSeoCostUsd * MARKUP)
* creditsCharged = ceil(billedUsd * 1000)
* 1 credit = $0.001 (1,000 credits = $1.00)
* ------------------------------------------------------------------ */
const MARKUP = 1.28; // OpenSEO's flat 28% premium over raw DataForSEO cost
const CREDIT_USD = 0.001; // $ value of a single credit
const BASE_PRICE_USD = 10; // Base Plan / month
const BASE_INCLUDED_CREDIT_USD = 10; // $10 of usage credits included, reset monthly
const WEEKS_PER_MONTH = 4.345; // 52 / 12
const DEFAULT_RANK_DEPTH = 40;
const DEFAULT_LOCAL_SERP_DEPTH = 20;
const BACKLINK_HISTORY_DAYS = 365;
const RANK_CHECK_OPTIONS = [0, 1, 7] as const;
const RANK_CHECK_LABELS: Record<number, string> = {
0: "Manual",
1: "Weekly",
7: "Daily",
};
// Raw DataForSEO per-call cost in USD (NOT including OpenSEO's markup).
const RAW_COST_USD = {
// Scheduled checks use the queued API. The app defaults to one device and
// the top 40 results: $0.0006 for page one + $0.00045 per extra page.
rankCheck: 0.0006 + (DEFAULT_RANK_DEPTH / 10 - 1) * 0.00045,
// A 150300 result Labs search is currently $0.030$0.048 raw. Use the
// midpoint so the customer estimate is a memorable $0.05 per search.
keywordLabs: 0.039,
// The MCP-only local SERP tool defaults to a live Google Maps/Local Finder
// request with 20 results: $0.002 for page one + $0.0015 for page two.
localSerp: 0.002 + (DEFAULT_LOCAL_SERP_DEPTH / 10 - 1) * 0.0015,
// A domain overview loads a summary plus one year of daily history. Current
// Backlinks API pricing is $0.024/request + $0.000036/result for each call.
backlinkProfile:
0.024 + 0.000036 + (0.024 + BACKLINK_HISTORY_DAYS * 0.000036),
aiCitationPerPlatform: 0.85, // AI-citation / brand scan, per platform (biggest driver)
} as const;
// Ahrefs Lite list price, verified 2026-07-01 (ahrefs.com/pricing).
const COMPETITORS = {
ahrefsLite: 129,
} as const;
/** creditsCharged for a single action, per the billing formula. */
function creditsForRaw(rawUsd: number): number {
const billedUsd = Math.round(rawUsd * MARKUP * 100_000) / 100_000;
return Math.ceil(billedUsd * 1000);
}
// Credits charged per unit of each action (computed once from the model above).
const CREDITS_PER_UNIT = {
keywordLabs: creditsForRaw(RAW_COST_USD.keywordLabs), // 50
localSerp: creditsForRaw(RAW_COST_USD.localSerp), // 5
backlinkProfile: creditsForRaw(RAW_COST_USD.backlinkProfile), // 79
aiCitation: creditsForRaw(RAW_COST_USD.aiCitationPerPlatform), // 1088
} as const;
/* ------------------------------------------------------------------ *
* Personas preset the estimator to the two modeled customers.
* ------------------------------------------------------------------ */
type Inputs = {
sites: number;
keywordsPerSite: number;
checksPerWeek: number;
keywordRuns: number; // keyword-research runs / month
localSerps: number; // MCP-only Google Maps / Local Finder SERPs per month
backlinks: number; // backlink profile lookups / month
aiScans: number; // AI-citation scans / month (per platform)
};
const PRESETS: Record<"business" | "freelancer", Inputs> = {
// About $8/mo of usage: one site with weekly rank tracking and regular research.
business: {
sites: 1,
keywordsPerSite: 50,
checksPerWeek: 1,
keywordRuns: 100,
localSerps: 0,
backlinks: 20,
aiScans: 0,
},
// About $25/mo of usage: an agency checking 15 client sites weekly.
freelancer: {
sites: 15,
keywordsPerSite: 20,
checksPerWeek: 1,
keywordRuns: 370,
localSerps: 200,
backlinks: 30,
aiScans: 0,
},
};
const usd = (n: number) =>
n >= 100 ? `$${Math.round(n).toLocaleString()}` : `$${n.toFixed(2)}`;
function Pricing() {
const [persona, setPersona] = useState<"business" | "freelancer">("business");
const [inputs, setInputs] = useState<Inputs>(PRESETS.business);
function applyPersona(next: "business" | "freelancer") {
setPersona(next);
setInputs(PRESETS[next]);
}
function set<K extends keyof Inputs>(key: K, value: number) {
setInputs((prev) => ({ ...prev, [key]: value }));
}
const estimate = useMemo(() => {
const scheduledRunsPerMonth =
inputs.sites * inputs.checksPerWeek * WEEKS_PER_MONTH;
const rankChecksPerMonth = Math.round(
scheduledRunsPerMonth * inputs.keywordsPerSite,
);
const lines = [
{
key: "keywords",
label: "Keyword research",
detail: `${inputs.keywordRuns.toLocaleString()} searches this month`,
credits: inputs.keywordRuns * CREDITS_PER_UNIT.keywordLabs,
},
{
key: "backlinks",
label: "Backlink checks",
detail: `${inputs.backlinks.toLocaleString()} checks this month`,
credits: inputs.backlinks * CREDITS_PER_UNIT.backlinkProfile,
},
{
key: "ai",
label: "ChatGPT brand checks",
detail: `${inputs.aiScans.toLocaleString()} checks this month`,
credits: inputs.aiScans * CREDITS_PER_UNIT.aiCitation,
},
...(persona === "freelancer"
? [
{
key: "local-serp",
label: "Local SERP checks",
detail: `${inputs.localSerps.toLocaleString()} checks this month`,
credits: inputs.localSerps * CREDITS_PER_UNIT.localSerp,
},
]
: []),
{
key: "rank",
label: "Rank tracking",
detail: `${rankChecksPerMonth.toLocaleString()} checks this month`,
// The app bills each site's scheduled run as one keyword batch.
credits: Math.round(
scheduledRunsPerMonth *
creditsForRaw(inputs.keywordsPerSite * RAW_COST_USD.rankCheck),
),
},
];
const totalCredits = lines.reduce((sum, l) => sum + l.credits, 0);
const usageUsd = totalCredits * CREDIT_USD;
const includedInBase = usageUsd <= BASE_INCLUDED_CREDIT_USD;
const topUpUsd = Math.max(0, usageUsd - BASE_INCLUDED_CREDIT_USD);
const billUsd = Math.max(BASE_PRICE_USD, usageUsd);
return { lines, usageUsd, includedInBase, topUpUsd, billUsd };
}, [inputs, persona]);
return (
<article className="mx-auto max-w-5xl">
<article className="mx-auto max-w-4xl">
{/* 1. Hero */}
<p className="text-sm font-medium text-[var(--color-brand-accent)]">
Pricing
</p>
<h1 className="mt-3 max-w-3xl text-4xl font-semibold leading-tight tracking-tight text-neutral-950 md:text-6xl">
Start small, scale by usage
<h1 className="mt-3 max-w-3xl text-4xl font-semibold leading-tight tracking-tight text-neutral-950 md:text-5xl">
Starts at $10/month
</h1>
<p className="mt-5 max-w-2xl text-lg leading-8 text-[var(--color-brand-muted)]">
Use the managed app for $10/month, or self-host OpenSEO with your own
API keys.
<p className="mt-4 max-w-2xl text-base leading-7 text-[var(--color-brand-muted)]">
Other SEO tools are too expensive. OpenSEO grows with you.
</p>
{/* Managed */}
<section className="mt-12">
<h2 className="text-2xl font-semibold tracking-tight text-neutral-950">
Managed
</h2>
<div className="mt-6 overflow-hidden rounded-xl border border-[var(--color-border-subtle)] bg-white">
{/* Base Plan */}
<div className="p-6">
<div className="flex items-baseline justify-between gap-4">
<p className="font-semibold text-neutral-950">Base Plan</p>
<p className="text-xl font-semibold tabular-nums text-neutral-950">
$10/month
</p>
</div>
<ul className="mt-3 space-y-2">
{[
"Keyword research, backlinks, rank tracking, and site audits",
"MCP server and agent skills for Claude, Cursor, and ChatGPT",
"Search Console integration that never uses credits",
"Includes $10.00 of Usage Credits each billing cycle",
].map((item) => (
<li
key={item}
className="flex gap-2.5 text-sm text-neutral-700"
>
<span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--color-brand-accent)]">
<span className="sr-only">Included:</span>
</span>
{item}
</li>
))}
</ul>
</div>
{/* Usage Credits — add-on, visually connected to Base Plan */}
<div className="border-t border-dashed border-[var(--color-border-subtle)] bg-[#fbfaf8] p-6">
<div className="flex items-baseline gap-3">
<p className="text-sm font-semibold text-neutral-950">
Usage Credits
</p>
<span className="text-xs font-medium text-[var(--color-brand-muted)]">
add-on
</span>
</div>
<p className="mt-2 text-sm leading-6 text-[var(--color-brand-muted)]">
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="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--color-brand-accent)]">
<span className="sr-only">Included:</span>
</span>
{item}
</li>
))}
</ul>
</div>
</div>
<a
href="https://app.openseo.so/sign-up"
className="mt-4 inline-flex items-center justify-center rounded-lg bg-neutral-950 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
Get Started{" "}
<span aria-hidden="true" className="ml-1.5">
&rarr;
</span>
</a>
<p className="mt-3 text-xs text-neutral-500">
<MoneyBackGuarantee />.
</p>
</section>
{/* Self-hosted */}
<section className="mt-12 rounded-xl border border-[var(--color-border-subtle)] bg-white p-6">
{/* 2. Base Plan */}
<section className="mt-10 border-y border-[var(--color-border-subtle)] py-8">
<div className="flex items-baseline justify-between gap-4">
<h2 className="text-2xl font-semibold tracking-tight text-neutral-950">
Self-hosted
</h2>
<div>
<p className="font-semibold text-neutral-950">Base Plan</p>
<p className="mt-1 text-sm text-[var(--color-brand-muted)]">
One plan. Everything included.
</p>
</div>
<p className="text-2xl font-semibold tabular-nums text-neutral-950">
$10
<span className="text-base font-normal text-[var(--color-brand-muted)]">
/mo
</span>
</p>
</div>
<ul className="mt-4 space-y-2">
{[
"Keyword research, backlinks, rank tracking, and site audits",
"Works inside Claude, Cursor, and ChatGPT",
"Google Search Console data is free and doesn't touch your $10",
"Includes $10 of usage every month",
"Buy extra usage anytime; it never expires",
].map((item) => (
<li key={item} className="flex gap-2.5 text-sm text-neutral-700">
<span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--color-brand-accent)]">
<span className="sr-only">Included:</span>
</span>
{item}
</li>
))}
</ul>
<div className="mt-6 flex items-center gap-4">
<a
href="https://app.openseo.so/sign-up"
className="inline-flex items-center justify-center rounded-lg bg-neutral-950 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
Get Started
<span aria-hidden="true" className="ml-1.5">
&rarr;
</span>
</a>
<p className="text-xs text-neutral-500">Try for free</p>
</div>
<p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--color-brand-muted)]">
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="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-neutral-950 transition-colors hover:text-[var(--color-brand-accent)]"
>
View on GitHub
<span aria-hidden="true">&rarr;</span>
</a>
</section>
{/* FAQ */}
<section className="mt-12">
<h2 className="text-2xl font-semibold tracking-tight text-neutral-950">
{/* 3. The estimator */}
<section className="mt-10">
<div className="grid divide-y divide-[var(--color-border-subtle)] rounded-xl border border-[var(--color-border-subtle)] bg-white lg:grid-cols-[1.1fr_1fr] lg:divide-x lg:divide-y-0">
{/* Inputs */}
<div className="p-5 sm:p-6">
<h2 className="text-lg font-semibold tracking-tight text-neutral-950">
Estimate your month
</h2>
{/* Persona toggle */}
<div
className="mt-4 inline-flex rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-1"
role="group"
aria-label="Choose a starting point"
>
{(
[
["business", "My own business"],
["freelancer", "Freelancer / agency"],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => applyPersona(value)}
aria-pressed={persona === value}
className={`rounded-md px-3.5 py-1.5 text-sm font-medium transition-colors ${
persona === value
? "border border-[var(--color-border-subtle)] bg-white text-neutral-950 shadow-sm"
: "border border-transparent text-neutral-600 hover:text-neutral-950"
}`}
>
{label}
</button>
))}
</div>
<div className="mt-5 space-y-4">
<Slider
label="Keyword searches / month"
hint="About $0.05 per search at typical result limits."
value={inputs.keywordRuns}
min={0}
max={1000}
step={10}
onChange={(v) => set("keywordRuns", v)}
/>
<Slider
label="Backlink checks / month"
hint="About $0.08 for a domain overview with one year of history."
value={inputs.backlinks}
min={0}
max={100}
onChange={(v) => set("backlinks", v)}
/>
<Slider
label="ChatGPT brand checks / month"
hint="This is the expensive one, about $1.09 each."
value={inputs.aiScans}
min={0}
max={50}
onChange={(v) => set("aiScans", v)}
/>
{persona === "freelancer" ? (
<>
<Slider
label="Local SERP checks / month"
hint="Google Maps or Local Finder via MCP, about $0.005 per check."
value={inputs.localSerps}
min={0}
max={1000}
step={10}
onChange={(v) => set("localSerps", v)}
/>
<Slider
label="Websites"
value={inputs.sites}
min={1}
max={50}
onChange={(v) => set("sites", v)}
/>
</>
) : null}
<Slider
label="Keywords tracked per site"
value={inputs.keywordsPerSite}
min={0}
max={200}
step={5}
onChange={(v) => set("keywordsPerSite", v)}
/>
<Slider
label="Rank tracking frequency"
value={inputs.checksPerWeek}
valueLabel={RANK_CHECK_LABELS[inputs.checksPerWeek]}
options={RANK_CHECK_OPTIONS}
onChange={(v) => set("checksPerWeek", v)}
/>
</div>
</div>
{/* Results */}
<div className="p-5 sm:p-6">
<p className="text-sm text-[var(--color-brand-muted)]">
Your estimated bill
</p>
<p className="mt-1 text-4xl font-semibold tabular-nums tracking-tight text-neutral-950">
{estimate.includedInBase
? `$${BASE_PRICE_USD}`
: usd(estimate.billUsd)}
<span className="text-lg font-normal text-[var(--color-brand-muted)]">
/mo
</span>
</p>
{estimate.includedInBase ? (
<p className="mt-2 text-sm leading-6 text-[var(--color-brand-muted)]">
<span
aria-hidden="true"
className="mr-1.5 inline-block h-1.5 w-1.5 rounded-full bg-emerald-500 align-middle"
/>
{usd(estimate.usageUsd)} of estimated usage fits inside the $
{BASE_INCLUDED_CREDIT_USD} already included.
</p>
) : (
<p className="mt-2 text-sm leading-6 tabular-nums text-[var(--color-brand-muted)]">
${BASE_PRICE_USD} plan + ~{usd(estimate.topUpUsd)} of extra
usage. Extra usage never expires.
</p>
)}
{/* Per-feature breakdown */}
<div className="mt-5 border-t border-[var(--color-border-subtle)] pt-5">
<p className="text-sm font-semibold text-neutral-950">
Where it goes
</p>
<dl className="mt-3 space-y-2.5">
{estimate.lines.map((line) => (
<div
key={line.key}
className="flex items-baseline justify-between gap-4"
>
<dt className="text-sm text-neutral-700">
{line.label}
<span className="block text-xs text-neutral-500">
{line.detail}
</span>
</dt>
<dd className="shrink-0 text-sm font-medium tabular-nums text-neutral-950">
{usd(line.credits * CREDIT_USD)}
</dd>
</div>
))}
</dl>
</div>
<p className="mt-5 border-t border-[var(--color-border-subtle)] pt-5 text-sm text-[var(--color-brand-muted)]">
For comparison: Ahrefs&apos; cheapest plan is{" "}
<span className="font-medium text-neutral-950">
{usd(COMPETITORS.ahrefsLite)}/mo
</span>
.
</p>
</div>
</div>
</section>
{/* 4. FAQ */}
<section className="mt-14">
<h2 className="text-xl font-semibold tracking-tight text-neutral-950">
FAQ
</h2>
<dl className="mt-5 divide-y divide-[var(--color-border-subtle)] rounded-xl border border-[var(--color-border-subtle)] bg-white">
<div className="p-5">
<dl className="mt-5 divide-y divide-[var(--color-border-subtle)]">
<div className="py-4 first:pt-0 last:pb-0">
<dt className="text-sm font-medium text-neutral-950">
Is there a free trial?
</dt>
<dd className="mt-1.5 text-sm leading-6 text-[var(--color-brand-muted)]">
No instead, every subscription comes with a 30-day money-back
guarantee. If OpenSEO isn&apos;t for you, email ben@openseo.so
within 30 days of your first charge and we&apos;ll refund it. You
can also self-host the open-source version for free.
Yes. The free trial includes $0.50 of credits so you can test
OpenSEO before subscribing.
</dd>
</div>
<div className="p-5">
<div className="py-4 first:pt-0 last:pb-0">
<dt className="text-sm font-medium text-neutral-950">
What if I use all my included credits?
What if I use all my credits for the month?
</dt>
<dd className="mt-1.5 text-sm leading-6 text-[var(--color-brand-muted)]">
You'll never have unexpected costs or bills. If you use all your
credits, you'll see errors when you try to do tasks. You can
purchase more top up credits at any time.
You&apos;ll never have unexpected costs or bills. If you use all
your credits, you&apos;ll see errors when you try to do tasks. You
can purchase more top up credits at any time.
</dd>
</div>
<div className="p-5">
<div className="py-4 first:pt-0 last:pb-0">
<dt className="text-sm font-medium text-neutral-950">
What features use credits?
</dt>
<dd className="mt-1.5 text-sm leading-6 text-[var(--color-brand-muted)]">
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.
Credits are consumed by features that query DataForSEO&apos;s API
backlinks, keyword volume, competitor data, and site audits.
Your projects, settings, and any data already fetched don&apos;t
cost credits.
</dd>
</div>
<div className="p-5">
<div className="py-4 first:pt-0 last:pb-0">
<dt className="text-sm font-medium text-neutral-950">
Do unused credits roll over?
</dt>
@ -178,24 +453,22 @@ function Pricing() {
with your Base Plan reset each billing cycle.
</dd>
</div>
<div className="p-5">
<div className="py-4 first:pt-0 last:pb-0">
<dt className="text-sm font-medium text-neutral-950">
Can I cancel anytime?
</dt>
<dd className="mt-1.5 text-sm leading-6 text-[var(--color-brand-muted)]">
Yes. Cancel from your billing portal at any time. Your access
continues through the end of the current billing period. Within
your first 30 days, you can email ben@openseo.so for a full
refund.{" "}
continues through the end of the current billing period.
</dd>
</div>
<div className="p-5">
<div className="py-4 first:pt-0 last:pb-0">
<dt className="text-sm font-medium text-neutral-950">
Do I need a subscription or just usage credits?
</dt>
<dd className="mt-1.5 text-sm leading-6 text-[var(--color-brand-muted)]">
While top-up Usage Credits roll over and don't expire, you need an
active subscription in order to use OpenSEO.
While top-up Usage Credits roll over and don&apos;t expire, you
need an active subscription in order to use OpenSEO.
</dd>
</div>
</dl>
@ -203,3 +476,61 @@ function Pricing() {
</article>
);
}
/* ------------------------------------------------------------------ *
* Slider native range input + live numeric readout.
* ------------------------------------------------------------------ */
function Slider({
label,
hint,
value,
min,
max,
options,
step = 1,
valueLabel,
onChange,
}: {
label: string;
hint?: string;
value: number;
min?: number;
max?: number;
options?: readonly number[];
step?: number;
valueLabel?: string;
onChange: (value: number) => void;
}) {
const optionIndex = options?.indexOf(value) ?? -1;
const sliderValue = options ? Math.max(0, optionIndex) : value;
const sliderMin = options ? 0 : (min ?? 0);
const sliderMax = options ? options.length - 1 : (max ?? 0);
return (
<div>
<div className="flex items-baseline justify-between gap-4">
<label className="text-sm font-medium text-neutral-800">{label}</label>
<span className="text-sm font-semibold tabular-nums text-neutral-950">
{valueLabel ?? value.toLocaleString()}
</span>
</div>
{hint ? (
<p className="mt-0.5 text-xs text-[var(--color-brand-muted)]">{hint}</p>
) : null}
<input
type="range"
min={sliderMin}
max={sliderMax}
step={options ? 1 : step}
value={sliderValue}
onChange={(e) => {
const next = Number(e.target.value);
onChange(options?.[next] ?? next);
}}
aria-label={label}
aria-valuetext={valueLabel}
className="mt-2 h-1 w-full cursor-pointer appearance-none rounded-full bg-[var(--color-border-subtle)] accent-[var(--color-brand-accent)]"
/>
</div>
);
}