diff --git a/.agents/PAPERCUTS.md b/.agents/PAPERCUTS.md index a3ac2b7..a50c79b 100644 --- a/.agents/PAPERCUTS.md +++ b/.agents/PAPERCUTS.md @@ -10,6 +10,7 @@ data, or sensitive paths. ## Open +- [ ] `2026-08-01T16:28:36Z` — `claude` — web's pinned wrangler 4.71.0 fails `kv namespace create` with a bare "Authentication error [code: 10000]" even though the OAuth token has workers_kv write scope; wrangler@4.118.0 succeeds with identical auth. Fix: bump wrangler in web/package.json. - [ ] `2026-07-20T20:08:28Z` — `claude` — In a fresh git worktree, `oxlint --type-aware` crashes with `Cannot find module '@oxlint/binding-darwin-arm64'` — the platform-specific optional dep is missing from the worktree's node_modules while tsc/prettier work fine, and plain `pnpm install` reports up-to-date without restoring it; `pnpm install --force` (~22s) fixes it. Worth making the worktree-setup hook (or a documented step) run the forced install so lint doesn't die on fresh worktrees. - [ ] `2026-07-19T04:06:52Z` — `codex` — `pnpm --dir web build` fails with `vite: command not found` when `web/node_modules` is absent, despite the root toolchain being installed. Document or enforce the package-local install required before validating the `web/` subpackage. - [ ] `2026-07-19T02:55:56Z` — `claude` — Adding a docs folder under `web/content/docs` whose `meta.json` lists an `[Overview](...)` link renders a duplicated, double-highlighted sidebar entry, because the folder-index strip in `web/src/lib/source.ts` (`transformPageTree.folder`) is a per-folder-name allowlist. Derive it from the meta convention (or strip the index for all folders) so new sections don't need a hidden source.ts edit. diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..91373dc --- /dev/null +++ b/web/.env.example @@ -0,0 +1,8 @@ +# Build-time vars (vite build inlines these). +VITE_TURNSTILE_SITE_KEY= + +# Runtime secrets — set with `wrangler secret put `, not here: +# DATAFORSEO_API_KEY base64("login:password") for api.dataforseo.com +# TURNSTILE_SECRET_KEY pairs with VITE_TURNSTILE_SITE_KEY; deploy the build +# containing the site key before setting this secret +# LOOPS_API_KEY newsletter subscribe endpoint diff --git a/web/.gitignore b/web/.gitignore index 6f0acfe..734f355 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -7,3 +7,4 @@ dist .source source.generated.ts *.local +.dev.vars* diff --git a/web/package.json b/web/package.json index a52bad5..b7e5933 100644 --- a/web/package.json +++ b/web/package.json @@ -15,7 +15,7 @@ "format:write": "prettier --write .", "deploy": "npm run build && wrangler deploy", "deploy:prod": "npm run build && wrangler deploy", - "deploy:preview": "npm run build && wrangler deploy --env preview" + "deploy:preview": "CLOUDFLARE_ENV=preview npm run build && wrangler deploy" }, "dependencies": { "@tanstack/react-router": "^1.168.10", diff --git a/web/public/social-card.jpg b/web/public/social-card.jpg new file mode 100644 index 0000000..1c13d8d Binary files /dev/null and b/web/public/social-card.jpg differ diff --git a/web/public/social-card.png b/web/public/social-card.png deleted file mode 100644 index cae7ce0..0000000 Binary files a/web/public/social-card.png and /dev/null differ diff --git a/web/scripts/generate-sitemap.js b/web/scripts/generate-sitemap.js index e7ff402..d7f5e05 100644 --- a/web/scripts/generate-sitemap.js +++ b/web/scripts/generate-sitemap.js @@ -25,6 +25,7 @@ const STATIC_PATHS = [ "/docs", "/features", "/features/mcp", + "/backlink-checker", "/open-source-seo", "/google-search-console-mcp", ...Object.values(FEATURE_PAGE_SLUGS).map((slug) => `/features/${slug}`), diff --git a/web/src/components/backlink-checker-tool.tsx b/web/src/components/backlink-checker-tool.tsx new file mode 100644 index 0000000..21e0ec3 --- /dev/null +++ b/web/src/components/backlink-checker-tool.tsx @@ -0,0 +1,396 @@ +import { type FormEvent, useEffect, useRef, useState } from "react"; + +// Public site key. Cloudflare's always-passing test key is the dev-only +// fallback; a prod build without VITE_TURNSTILE_SITE_KEY renders no widget. +const TURNSTILE_SITE_KEY = + (import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined) || + (import.meta.env.DEV ? "1x00000000000000000000AA" : ""); + +type TurnstileApi = { + render( + container: HTMLElement, + options: { + sitekey: string; + appearance?: string; + callback: (token: string) => void; + "expired-callback"?: () => void; + }, + ): string; + reset(widgetId?: string): void; +}; + +type BacklinkRow = { + domainFrom: string | null; + urlFrom: string | null; + urlTo: string | null; + pageTitle: string | null; + anchor: string | null; + dofollow: boolean | null; + domainRank: number | null; +}; + +type CheckResult = { + target: string; + summary: { + rank: number | null; + backlinks: number | null; + referringDomains: number | null; + brokenBacklinks: number | null; + }; + topBacklinks: BacklinkRow[]; +}; + +function formatCount(value: number | null): string { + return typeof value === "number" ? value.toLocaleString("en-US") : "—"; +} + +/** Small info icon that reveals an explanation on hover or focus. */ +function InfoTip({ + tip, + align = "center", +}: { + tip: string; + align?: "center" | "right"; +}) { + return ( + + + + + ); +} + +export function BacklinkCheckerTool() { + const [target, setTarget] = useState(""); + const [status, setStatus] = useState<"idle" | "loading" | "done" | "error">( + "idle", + ); + const [errorMessage, setErrorMessage] = useState(""); + const [result, setResult] = useState(null); + + const widgetContainerRef = useRef(null); + const widgetIdRef = useRef(null); + const tokenRef = useRef(""); + + useEffect(() => { + if (!TURNSTILE_SITE_KEY) return; + const w = window as unknown as { + turnstile?: TurnstileApi; + onloadTurnstileCallback?: () => void; + }; + const renderWidget = () => { + if (!w.turnstile || !widgetContainerRef.current) return; + if (widgetIdRef.current !== null) return; + widgetIdRef.current = w.turnstile.render(widgetContainerRef.current, { + sitekey: TURNSTILE_SITE_KEY, + // Invisible unless Turnstile decides the visitor needs a challenge. + appearance: "interaction-only", + callback: (token) => { + tokenRef.current = token; + }, + "expired-callback": () => { + tokenRef.current = ""; + }, + }); + }; + + if (w.turnstile) { + renderWidget(); + return; + } + w.onloadTurnstileCallback = renderWidget; + if (!document.querySelector("script[data-turnstile]")) { + const script = document.createElement("script"); + script.src = + "https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback"; + script.async = true; + script.dataset.turnstile = "true"; + document.head.appendChild(script); + } + }, []); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setStatus("loading"); + setErrorMessage(""); + try { + const res = await fetch("/api/backlink-check", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + target, + turnstileToken: tokenRef.current || undefined, + }), + }); + const data = (await res.json().catch(() => ({}))) as + | CheckResult + | { error?: string }; + if (!res.ok) { + throw new Error( + (data as { error?: string }).error || "Something went wrong", + ); + } + setResult(data as CheckResult); + setStatus("done"); + } catch (err) { + setStatus("error"); + setErrorMessage( + err instanceof Error ? err.message : "Something went wrong", + ); + } finally { + // Tokens are single-use; get a fresh one for the next check. + tokenRef.current = ""; + const w = window as unknown as { turnstile?: TurnstileApi }; + if (w.turnstile && widgetIdRef.current !== null) { + w.turnstile.reset(widgetIdRef.current); + } + } + }; + + return ( +
+
+
+ + setTarget(e.target.value)} + placeholder="example.com" + disabled={status === "loading"} + className="h-11 min-w-0 flex-1 rounded-lg border border-[var(--color-border-subtle)] bg-white px-3.5 text-base text-neutral-900 placeholder:text-neutral-500 transition focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900" + /> + +
+
+

+ Free · No signup · Instant results +

+ {status === "error" && ( +

{errorMessage}

+ )} + + + {status === "done" && result ? : null} +
+ ); +} + +function CheckResults({ result }: { result: CheckResult }) { + const { summary, topBacklinks } = result; + const total = summary.backlinks; + const hasMore = typeof total === "number" && total > topBacklinks.length; + + const metrics = [ + { + label: "Domain rank", + value: formatCount(summary.rank), + tip: "DataForSEO's 0-100 strength score for a domain's link profile. Similar idea to Ahrefs DR or Moz DA, but each tool uses its own index and formula, so numbers differ between tools.", + }, + { + label: "Backlinks", + value: formatCount(summary.backlinks), + tip: "Total individual links pointing at this domain, counting multiple links from the same website.", + }, + { + label: "Referring domains", + value: formatCount(summary.referringDomains), + tip: "Unique websites that link to this domain at least once.", + }, + { + label: "Broken backlinks", + value: formatCount(summary.brokenBacklinks), + tip: "Links pointing at pages on this domain that no longer load, such as deleted pages returning 404.", + }, + ]; + + return ( +
+

+ Backlink profile for{" "} + + {result.target} + +

+ +
+ {metrics.map((metric, index) => ( +
1 && "border-t border-[var(--color-border-subtle)]", + index > 0 && "md:border-l md:border-t-0", + ] + .filter(Boolean) + .join(" ")} + > +
+ {metric.label} + 1 ? "right" : "center"} + /> +
+
+ {metric.value} +
+
+ ))} +
+ + {topBacklinks.length > 0 ? ( +
+ + + + + + + + + + + {topBacklinks.map((row) => ( + + + + + + + ))} + +
+ Rank + + Referring page + Anchor and target + + + Type + +
+ {formatCount(row.domainRank)} + +

+ {row.pageTitle ?? row.domainFrom ?? "—"} +

+ {row.urlFrom ? ( + + {row.urlFrom} + + ) : null} +
+

+ {row.anchor ?? "—"} +

+ {row.urlTo ? ( +

+ {row.urlTo} +

+ ) : null} +
+ + {row.dofollow ? "Follow" : "Nofollow"} + +
+
+ ) : ( +

+ No live backlinks found for this domain in the index yet. +

+ )} + +
+

+ {hasMore ? ( + <> + Showing the top {topBacklinks.length} backlinks, one per referring + domain, strongest domains first.{" "} + + {formatCount(total)} total backlinks + {" "} + are in the index for this domain. + + ) : ( + <> + Explore the full picture: referring domains, anchors, new and lost + links, and spam signals. + + )} +

+ +
+
+ ); +} diff --git a/web/src/components/landing-page.tsx b/web/src/components/landing-page.tsx index 57bdf0f..f786bf5 100644 --- a/web/src/components/landing-page.tsx +++ b/web/src/components/landing-page.tsx @@ -304,7 +304,7 @@ const FEATURE_CARDS = [ blurb: "Estimate organic traffic and ranking keywords.", }, { - page: featurePages.backlinkChecker, + page: featurePages.backlinks, blurb: "Inspect backlinks, referring domains, and link quality.", }, { diff --git a/web/src/lib/feature-page-slugs.js b/web/src/lib/feature-page-slugs.js index 670e59e..dabb11f 100644 --- a/web/src/lib/feature-page-slugs.js +++ b/web/src/lib/feature-page-slugs.js @@ -1,7 +1,7 @@ export const FEATURE_PAGE_SLUGS = { keywordResearch: "keyword-research", siteAudit: "site-audit", - backlinkChecker: "backlink-checker", + backlinks: "backlinks", domainOverview: "domain-overview", rankTracking: "rank-tracking", savedKeywords: "saved-keywords", diff --git a/web/src/lib/feature-pages.ts b/web/src/lib/feature-pages.ts index de4f057..9671f68 100644 --- a/web/src/lib/feature-pages.ts +++ b/web/src/lib/feature-pages.ts @@ -208,7 +208,7 @@ export const featurePages = { ], related: [ { label: "Domain Overview", href: "/features/domain-overview" }, - { label: "Backlink Checker", href: "/features/backlink-checker" }, + { label: "Backlinks", href: "/features/backlinks" }, { label: "Keyword Research", href: "/features/keyword-research" }, ], faqs: [ @@ -229,18 +229,18 @@ export const featurePages = { }, ], }, - backlinkChecker: { - slug: FEATURE_PAGE_SLUGS.backlinkChecker, + backlinks: { + slug: FEATURE_PAGE_SLUGS.backlinks, eyebrow: "Backlinks", navDescription: "Check links and referring domains.", - title: "Backlink checker for understanding a domain's link profile", + title: "Backlink analysis for understanding a domain's link profile", description: "Analyze backlinks, referring domains, and linked pages without separating link research from the rest of your SEO workspace.", - primaryKeyword: "backlink checker", + primaryKeyword: "backlink analysis", secondaryKeywords: [ - "free backlink checker", "backlink analysis tool", - "google backlink checker", + "referring domains", + "link profile", ], imageAlt: "OpenSEO backlinks report", imageSrc: @@ -280,18 +280,18 @@ export const featurePages = { "MCP support lets an AI agent pull backlink context during SEO research.", ], related: [ + { label: "Free Backlink Checker", href: "/backlink-checker" }, { label: "Link Prospecting", href: "/docs/skills/link-prospecting", }, { label: "Domain Overview", href: "/features/domain-overview" }, - { label: "OpenSEO MCP", href: "/features/mcp" }, ], faqs: [ { - question: "What is a backlink checker used for?", + question: "What is backlink analysis used for?", answer: - "A backlink checker helps you understand which sites link to a domain or page, which links have stronger rank, spam, broken, lost, or nofollow signals, and where competitors are earning authority.", + "Backlink analysis helps you understand which sites link to a domain or page, which links have stronger rank, spam, broken, lost, or nofollow signals, and where competitors are earning authority.", }, { question: "Can I check competitor backlinks in OpenSEO?", @@ -361,7 +361,7 @@ export const featurePages = { href: "/docs/skills/competitor-analysis", }, { label: "Keyword Research", href: "/features/keyword-research" }, - { label: "Backlink Checker", href: "/features/backlink-checker" }, + { label: "Backlinks", href: "/features/backlinks" }, ], faqs: [ { @@ -695,7 +695,7 @@ export const featureGroups = [ description: "Understand competitors, backlinks, and technical health.", pages: [ featurePages.domainOverview, - featurePages.backlinkChecker, + featurePages.backlinks, featurePages.siteAudit, ], }, diff --git a/web/src/lib/seo.ts b/web/src/lib/seo.ts index 4567e24..4964c19 100644 --- a/web/src/lib/seo.ts +++ b/web/src/lib/seo.ts @@ -1,5 +1,5 @@ const DEFAULT_SITE_URL = "https://openseo.so"; -const DEFAULT_SOCIAL_IMAGE_PATH = "/social-card.png"; +const DEFAULT_SOCIAL_IMAGE_PATH = "/social-card.jpg"; const DEFAULT_SOCIAL_IMAGE_ALT = "OpenSEO product preview"; export const SITE_URL = ( @@ -53,7 +53,7 @@ export function buildPageSeo({ { property: "og:url", content: canonicalUrl }, { property: "og:image", content: socialImageUrl }, { property: "og:image:alt", content: imageAlt }, - { property: "og:image:type", content: "image/png" }, + { property: "og:image:type", content: "image/jpeg" }, { property: "og:image:width", content: "1200" }, { property: "og:image:height", content: "630" }, { name: "twitter:card", content: "summary_large_image" }, diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index d11eeb0..e20c10e 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -22,9 +22,11 @@ import { Route as DocsSplatRouteImport } from './routes/docs/$' 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 ApiBacklinkCheckRouteImport } from './routes/api/backlink-check' import { Route as MarketingPricingRouteImport } from './routes/_marketing/pricing' import { Route as MarketingOpenSourceSeoRouteImport } from './routes/_marketing/open-source-seo' import { Route as MarketingGoogleSearchConsoleMcpRouteImport } from './routes/_marketing/google-search-console-mcp' +import { Route as MarketingBacklinkCheckerRouteImport } from './routes/_marketing/backlink-checker' import { Route as MarketingFeaturesIndexRouteImport } from './routes/_marketing/features/index' import { Route as MarketingFeaturesSiteAuditRouteImport } from './routes/_marketing/features/site-audit' import { Route as MarketingFeaturesSavedKeywordsRouteImport } from './routes/_marketing/features/saved-keywords' @@ -32,6 +34,7 @@ import { Route as MarketingFeaturesRankTrackingRouteImport } from './routes/_mar import { Route as MarketingFeaturesMcpRouteImport } from './routes/_marketing/features/mcp' import { Route as MarketingFeaturesKeywordResearchRouteImport } from './routes/_marketing/features/keyword-research' import { Route as MarketingFeaturesDomainOverviewRouteImport } from './routes/_marketing/features/domain-overview' +import { Route as MarketingFeaturesBacklinksRouteImport } from './routes/_marketing/features/backlinks' import { Route as MarketingFeaturesBacklinkCheckerRouteImport } from './routes/_marketing/features/backlink-checker' import { Route as MarketingFeaturesAiSearchPromptsRouteImport } from './routes/_marketing/features/ai-search-prompts' import { Route as MarketingFeaturesAiBrandVisibilityRouteImport } from './routes/_marketing/features/ai-brand-visibility' @@ -105,6 +108,11 @@ const ApiEventRoute = ApiEventRouteImport.update({ path: '/api/event', getParentRoute: () => rootRouteImport, } as any) +const ApiBacklinkCheckRoute = ApiBacklinkCheckRouteImport.update({ + id: '/api/backlink-check', + path: '/api/backlink-check', + getParentRoute: () => rootRouteImport, +} as any) const MarketingPricingRoute = MarketingPricingRouteImport.update({ id: '/pricing', path: '/pricing', @@ -121,6 +129,12 @@ const MarketingGoogleSearchConsoleMcpRoute = path: '/google-search-console-mcp', getParentRoute: () => MarketingRoute, } as any) +const MarketingBacklinkCheckerRoute = + MarketingBacklinkCheckerRouteImport.update({ + id: '/backlink-checker', + path: '/backlink-checker', + getParentRoute: () => MarketingRoute, + } as any) const MarketingFeaturesIndexRoute = MarketingFeaturesIndexRouteImport.update({ id: '/features/', path: '/features/', @@ -161,6 +175,12 @@ const MarketingFeaturesDomainOverviewRoute = path: '/features/domain-overview', getParentRoute: () => MarketingRoute, } as any) +const MarketingFeaturesBacklinksRoute = + MarketingFeaturesBacklinksRouteImport.update({ + id: '/features/backlinks', + path: '/features/backlinks', + getParentRoute: () => MarketingRoute, + } as any) const MarketingFeaturesBacklinkCheckerRoute = MarketingFeaturesBacklinkCheckerRouteImport.update({ id: '/features/backlink-checker', @@ -214,9 +234,11 @@ export interface FileRoutesByFullPath { '/': typeof MarketingIndexRoute '/privacy': typeof PrivacyRoute '/terms-and-conditions': typeof TermsAndConditionsRoute + '/backlink-checker': typeof MarketingBacklinkCheckerRoute '/google-search-console-mcp': typeof MarketingGoogleSearchConsoleMcpRoute '/open-source-seo': typeof MarketingOpenSourceSeoRoute '/pricing': typeof MarketingPricingRoute + '/api/backlink-check': typeof ApiBacklinkCheckRoute '/api/event': typeof ApiEventRoute '/api/subscribe': typeof ApiSubscribeRoute '/blogs/$': typeof BlogsSplatRoute @@ -229,6 +251,7 @@ export interface FileRoutesByFullPath { '/features/ai-brand-visibility': typeof MarketingFeaturesAiBrandVisibilityRoute '/features/ai-search-prompts': typeof MarketingFeaturesAiSearchPromptsRoute '/features/backlink-checker': typeof MarketingFeaturesBacklinkCheckerRoute + '/features/backlinks': typeof MarketingFeaturesBacklinksRoute '/features/domain-overview': typeof MarketingFeaturesDomainOverviewRoute '/features/keyword-research': typeof MarketingFeaturesKeywordResearchRoute '/features/mcp': typeof MarketingFeaturesMcpRoute @@ -245,9 +268,11 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/privacy': typeof PrivacyRoute '/terms-and-conditions': typeof TermsAndConditionsRoute + '/backlink-checker': typeof MarketingBacklinkCheckerRoute '/google-search-console-mcp': typeof MarketingGoogleSearchConsoleMcpRoute '/open-source-seo': typeof MarketingOpenSourceSeoRoute '/pricing': typeof MarketingPricingRoute + '/api/backlink-check': typeof ApiBacklinkCheckRoute '/api/event': typeof ApiEventRoute '/api/subscribe': typeof ApiSubscribeRoute '/blogs/$': typeof BlogsSplatRoute @@ -261,6 +286,7 @@ export interface FileRoutesByTo { '/features/ai-brand-visibility': typeof MarketingFeaturesAiBrandVisibilityRoute '/features/ai-search-prompts': typeof MarketingFeaturesAiSearchPromptsRoute '/features/backlink-checker': typeof MarketingFeaturesBacklinkCheckerRoute + '/features/backlinks': typeof MarketingFeaturesBacklinksRoute '/features/domain-overview': typeof MarketingFeaturesDomainOverviewRoute '/features/keyword-research': typeof MarketingFeaturesKeywordResearchRoute '/features/mcp': typeof MarketingFeaturesMcpRoute @@ -279,9 +305,11 @@ export interface FileRoutesById { '/_marketing': typeof MarketingRouteWithChildren '/privacy': typeof PrivacyRoute '/terms-and-conditions': typeof TermsAndConditionsRoute + '/_marketing/backlink-checker': typeof MarketingBacklinkCheckerRoute '/_marketing/google-search-console-mcp': typeof MarketingGoogleSearchConsoleMcpRoute '/_marketing/open-source-seo': typeof MarketingOpenSourceSeoRoute '/_marketing/pricing': typeof MarketingPricingRoute + '/api/backlink-check': typeof ApiBacklinkCheckRoute '/api/event': typeof ApiEventRoute '/api/subscribe': typeof ApiSubscribeRoute '/blogs/$': typeof BlogsSplatRoute @@ -295,6 +323,7 @@ export interface FileRoutesById { '/_marketing/features/ai-brand-visibility': typeof MarketingFeaturesAiBrandVisibilityRoute '/_marketing/features/ai-search-prompts': typeof MarketingFeaturesAiSearchPromptsRoute '/_marketing/features/backlink-checker': typeof MarketingFeaturesBacklinkCheckerRoute + '/_marketing/features/backlinks': typeof MarketingFeaturesBacklinksRoute '/_marketing/features/domain-overview': typeof MarketingFeaturesDomainOverviewRoute '/_marketing/features/keyword-research': typeof MarketingFeaturesKeywordResearchRoute '/_marketing/features/mcp': typeof MarketingFeaturesMcpRoute @@ -314,9 +343,11 @@ export interface FileRouteTypes { | '/' | '/privacy' | '/terms-and-conditions' + | '/backlink-checker' | '/google-search-console-mcp' | '/open-source-seo' | '/pricing' + | '/api/backlink-check' | '/api/event' | '/api/subscribe' | '/blogs/$' @@ -329,6 +360,7 @@ export interface FileRouteTypes { | '/features/ai-brand-visibility' | '/features/ai-search-prompts' | '/features/backlink-checker' + | '/features/backlinks' | '/features/domain-overview' | '/features/keyword-research' | '/features/mcp' @@ -345,9 +377,11 @@ export interface FileRouteTypes { to: | '/privacy' | '/terms-and-conditions' + | '/backlink-checker' | '/google-search-console-mcp' | '/open-source-seo' | '/pricing' + | '/api/backlink-check' | '/api/event' | '/api/subscribe' | '/blogs/$' @@ -361,6 +395,7 @@ export interface FileRouteTypes { | '/features/ai-brand-visibility' | '/features/ai-search-prompts' | '/features/backlink-checker' + | '/features/backlinks' | '/features/domain-overview' | '/features/keyword-research' | '/features/mcp' @@ -378,9 +413,11 @@ export interface FileRouteTypes { | '/_marketing' | '/privacy' | '/terms-and-conditions' + | '/_marketing/backlink-checker' | '/_marketing/google-search-console-mcp' | '/_marketing/open-source-seo' | '/_marketing/pricing' + | '/api/backlink-check' | '/api/event' | '/api/subscribe' | '/blogs/$' @@ -394,6 +431,7 @@ export interface FileRouteTypes { | '/_marketing/features/ai-brand-visibility' | '/_marketing/features/ai-search-prompts' | '/_marketing/features/backlink-checker' + | '/_marketing/features/backlinks' | '/_marketing/features/domain-overview' | '/_marketing/features/keyword-research' | '/_marketing/features/mcp' @@ -412,6 +450,7 @@ export interface RootRouteChildren { MarketingRoute: typeof MarketingRouteWithChildren PrivacyRoute: typeof PrivacyRoute TermsAndConditionsRoute: typeof TermsAndConditionsRoute + ApiBacklinkCheckRoute: typeof ApiBacklinkCheckRoute ApiEventRoute: typeof ApiEventRoute ApiSubscribeRoute: typeof ApiSubscribeRoute BlogsSplatRoute: typeof BlogsSplatRoute @@ -516,6 +555,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiEventRouteImport parentRoute: typeof rootRouteImport } + '/api/backlink-check': { + id: '/api/backlink-check' + path: '/api/backlink-check' + fullPath: '/api/backlink-check' + preLoaderRoute: typeof ApiBacklinkCheckRouteImport + parentRoute: typeof rootRouteImport + } '/_marketing/pricing': { id: '/_marketing/pricing' path: '/pricing' @@ -537,6 +583,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MarketingGoogleSearchConsoleMcpRouteImport parentRoute: typeof MarketingRoute } + '/_marketing/backlink-checker': { + id: '/_marketing/backlink-checker' + path: '/backlink-checker' + fullPath: '/backlink-checker' + preLoaderRoute: typeof MarketingBacklinkCheckerRouteImport + parentRoute: typeof MarketingRoute + } '/_marketing/features/': { id: '/_marketing/features/' path: '/features' @@ -586,6 +639,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MarketingFeaturesDomainOverviewRouteImport parentRoute: typeof MarketingRoute } + '/_marketing/features/backlinks': { + id: '/_marketing/features/backlinks' + path: '/features/backlinks' + fullPath: '/features/backlinks' + preLoaderRoute: typeof MarketingFeaturesBacklinksRouteImport + parentRoute: typeof MarketingRoute + } '/_marketing/features/backlink-checker': { id: '/_marketing/features/backlink-checker' path: '/features/backlink-checker' @@ -646,6 +706,7 @@ declare module '@tanstack/react-router' { } interface MarketingRouteChildren { + MarketingBacklinkCheckerRoute: typeof MarketingBacklinkCheckerRoute MarketingGoogleSearchConsoleMcpRoute: typeof MarketingGoogleSearchConsoleMcpRoute MarketingOpenSourceSeoRoute: typeof MarketingOpenSourceSeoRoute MarketingPricingRoute: typeof MarketingPricingRoute @@ -653,6 +714,7 @@ interface MarketingRouteChildren { MarketingFeaturesAiBrandVisibilityRoute: typeof MarketingFeaturesAiBrandVisibilityRoute MarketingFeaturesAiSearchPromptsRoute: typeof MarketingFeaturesAiSearchPromptsRoute MarketingFeaturesBacklinkCheckerRoute: typeof MarketingFeaturesBacklinkCheckerRoute + MarketingFeaturesBacklinksRoute: typeof MarketingFeaturesBacklinksRoute MarketingFeaturesDomainOverviewRoute: typeof MarketingFeaturesDomainOverviewRoute MarketingFeaturesKeywordResearchRoute: typeof MarketingFeaturesKeywordResearchRoute MarketingFeaturesMcpRoute: typeof MarketingFeaturesMcpRoute @@ -668,6 +730,7 @@ interface MarketingRouteChildren { } const MarketingRouteChildren: MarketingRouteChildren = { + MarketingBacklinkCheckerRoute: MarketingBacklinkCheckerRoute, MarketingGoogleSearchConsoleMcpRoute: MarketingGoogleSearchConsoleMcpRoute, MarketingOpenSourceSeoRoute: MarketingOpenSourceSeoRoute, MarketingPricingRoute: MarketingPricingRoute, @@ -676,6 +739,7 @@ const MarketingRouteChildren: MarketingRouteChildren = { MarketingFeaturesAiBrandVisibilityRoute, MarketingFeaturesAiSearchPromptsRoute: MarketingFeaturesAiSearchPromptsRoute, MarketingFeaturesBacklinkCheckerRoute: MarketingFeaturesBacklinkCheckerRoute, + MarketingFeaturesBacklinksRoute: MarketingFeaturesBacklinksRoute, MarketingFeaturesDomainOverviewRoute: MarketingFeaturesDomainOverviewRoute, MarketingFeaturesKeywordResearchRoute: MarketingFeaturesKeywordResearchRoute, MarketingFeaturesMcpRoute: MarketingFeaturesMcpRoute, @@ -703,6 +767,7 @@ const rootRouteChildren: RootRouteChildren = { MarketingRoute: MarketingRouteWithChildren, PrivacyRoute: PrivacyRoute, TermsAndConditionsRoute: TermsAndConditionsRoute, + ApiBacklinkCheckRoute: ApiBacklinkCheckRoute, ApiEventRoute: ApiEventRoute, ApiSubscribeRoute: ApiSubscribeRoute, BlogsSplatRoute: BlogsSplatRoute, diff --git a/web/src/routes/_marketing/backlink-checker.tsx b/web/src/routes/_marketing/backlink-checker.tsx new file mode 100644 index 0000000..9145db6 --- /dev/null +++ b/web/src/routes/_marketing/backlink-checker.tsx @@ -0,0 +1,151 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { BacklinkCheckerTool } from "@/components/backlink-checker-tool"; +import { buildPageSeo } from "@/lib/seo"; + +export const Route = createFileRoute("/_marketing/backlink-checker")({ + head: () => + buildPageSeo({ + title: "Free Backlink Checker: Check Backlinks to Any Website", + description: + "Check backlinks for any domain: referring domains, top backlinks, anchor text, and follow status. Instant results, no signup required.", + path: "/backlink-checker", + titleSuffix: "OpenSEO", + imageAlt: "OpenSEO free backlink checker", + }), + component: BacklinkCheckerPage, +}); + +const FAQS = [ + { + question: "Where does the backlink data come from?", + answer: + "Results come from DataForSEO's link index, the same data source that powers backlink research inside OpenSEO. The index is refreshed continuously, so counts can differ slightly from other tools that crawl the web on their own schedule.", + }, + { + question: "How many backlinks can I see for free?", + answer: + "The free checker shows a domain's summary metrics and its top 15 backlinks, one per referring domain, ranked by domain strength. Sign up for OpenSEO to page through the full list, see referring domains and anchors, filter out spam, and export the data.", + }, + { + question: "Can I check a competitor's backlinks?", + answer: + "Yes. Enter any domain — yours, a competitor's, or a site you're evaluating for outreach. Backlink data is public-web data, so no site ownership or verification is needed.", + }, + { + question: "What is domain rank?", + answer: + "Domain rank is a 0-100 score of a domain's link-profile strength, similar to domain authority metrics in other tools. Higher means the domain has more and stronger links pointing at it.", + }, +]; + +function BacklinkCheckerPage() { + return ( +
+
+

+ Free tool +

+

+ Free Backlink Checker +

+

+ Check the backlinks of any website. Enter a domain and get its domain + rank, referring domains, and top backlinks with anchor text and follow + status. +

+
+ +
+ +
+ +
+

+ What you get +

+
    + {[ + { + title: "Link profile summary", + description: + "Domain rank, total backlinks, referring domains, and broken backlinks for the domain you check.", + }, + { + title: "Top backlinks", + description: + "The strongest links pointing at the domain, one per referring domain, with anchor text and follow status.", + }, + { + title: "Competitor visibility", + description: + "Works on any domain, so you can see who links to competitors and where their authority comes from.", + }, + ].map((item, index) => ( +
  1. + + {String(index + 1).padStart(2, "0")} + +

    + {item.title} +

    +

    + {item.description} +

    +
  2. + ))} +
+
+ +
+

+ FAQ +

+
+ {FAQS.map((faq) => ( +
+

+ {faq.question} +

+

+ {faq.answer} +

+
+ ))} +
+
+ +
+

+ Go deeper than a spot check +

+

+ OpenSEO puts full backlink analysis next to keyword research, rank + tracking, and site audits — open source, starting free. +

+ +
+
+ ); +} diff --git a/web/src/routes/_marketing/features/backlink-checker.tsx b/web/src/routes/_marketing/features/backlink-checker.tsx index 5d5c43c..808c447 100644 --- a/web/src/routes/_marketing/features/backlink-checker.tsx +++ b/web/src/routes/_marketing/features/backlink-checker.tsx @@ -1,18 +1,10 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { FeaturePageTemplate } from "@/components/feature-page"; -import { featurePages } from "@/lib/feature-pages"; -import { buildPageSeo } from "@/lib/seo"; - -const page = featurePages.backlinkChecker; +import { createFileRoute, redirect } from "@tanstack/react-router"; +// The old feature page ranks for "backlink checker"; that intent is now served +// by the free tool at /backlink-checker. The feature content moved to +// /features/backlinks. export const Route = createFileRoute("/_marketing/features/backlink-checker")({ - head: () => - buildPageSeo({ - title: "Backlink Checker", - description: page.description, - path: "/features/backlink-checker", - titleSuffix: "OpenSEO", - imageAlt: page.imageAlt, - }), - component: () => , + beforeLoad: () => { + throw redirect({ to: "/backlink-checker", statusCode: 301 }); + }, }); diff --git a/web/src/routes/_marketing/features/backlinks.tsx b/web/src/routes/_marketing/features/backlinks.tsx new file mode 100644 index 0000000..1cacca4 --- /dev/null +++ b/web/src/routes/_marketing/features/backlinks.tsx @@ -0,0 +1,18 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { FeaturePageTemplate } from "@/components/feature-page"; +import { featurePages } from "@/lib/feature-pages"; +import { buildPageSeo } from "@/lib/seo"; + +const page = featurePages.backlinks; + +export const Route = createFileRoute("/_marketing/features/backlinks")({ + head: () => + buildPageSeo({ + title: "Backlink Analysis Tool", + description: page.description, + path: "/features/backlinks", + titleSuffix: "OpenSEO", + imageAlt: page.imageAlt, + }), + component: () => , +}); diff --git a/web/src/routes/_marketing/google-search-console-mcp.tsx b/web/src/routes/_marketing/google-search-console-mcp.tsx index f912f9c..4d381f9 100644 --- a/web/src/routes/_marketing/google-search-console-mcp.tsx +++ b/web/src/routes/_marketing/google-search-console-mcp.tsx @@ -75,8 +75,8 @@ function GoogleSearchConsoleMcpPage() {

- $10/month, 30-day money-back guarantee. Search Console tools never - use credits. + $10/month, 30-day money-back guarantee. Search Console tools never use + credits.

diff --git a/web/src/routes/api/backlink-check.ts b/web/src/routes/api/backlink-check.ts new file mode 100644 index 0000000..302801c --- /dev/null +++ b/web/src/routes/api/backlink-check.ts @@ -0,0 +1,330 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { env } from "cloudflare:workers"; +import { z } from "zod"; + +const DATAFORSEO_BASE = "https://api.dataforseo.com"; +const TOP_BACKLINKS_LIMIT = 15; +const CACHE_TTL_SECONDS = 86_400; +// Hard ceiling on paid DataForSEO lookups per day (~$0.04 each). Cached +// checks don't count. Bumping this is a deliberate spend decision. +const DAILY_CHECK_BUDGET = 500; + +const requestSchema = z.object({ + target: z.string().trim().min(1, "Enter a domain").max(300), + turnstileToken: z.string().max(4096).optional(), +}); + +// DataForSEO task envelope: HTTP 200 with per-task status codes; 20000 = ok. +const taskEnvelopeSchema = z.object({ + tasks: z + .array( + z + .object({ + status_code: z.number().optional(), + status_message: z.string().optional(), + result: z + .array(z.record(z.string(), z.unknown())) + .nullable() + .optional(), + }) + .passthrough(), + ) + .optional(), +}); + +const summaryResultSchema = z + .object({ + rank: z.number().nullable().optional(), + backlinks: z.number().nullable().optional(), + referring_domains: z.number().nullable().optional(), + broken_backlinks: z.number().nullable().optional(), + }) + .passthrough(); + +const backlinksResultSchema = z + .object({ + items: z + .array( + z + .object({ + type: z.string().nullable().optional(), + domain_from: z.string().nullable().optional(), + url_from: z.string().nullable().optional(), + url_to: z.string().nullable().optional(), + anchor: z.string().nullable().optional(), + dofollow: z.boolean().nullable().optional(), + domain_from_rank: z.number().nullable().optional(), + page_from_title: z.string().nullable().optional(), + }) + .passthrough(), + ) + .nullable() + .optional(), + }) + .passthrough(); + +type RateLimiter = { + limit(options: { key: string }): Promise<{ success: boolean }>; +}; + +type KvStore = { + get(key: string): Promise; + put( + key: string, + value: string, + options?: { expirationTtl?: number }, + ): Promise; +}; + +function normalizeDomain(input: string): string | null { + let hostname: string; + try { + hostname = new URL(input.includes("://") ? input : `https://${input}`) + .hostname; + } catch { + return null; + } + const domain = hostname.replace(/^www\./, "").toLowerCase(); + const isValid = + /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test( + domain, + ); + return isValid ? domain : null; +} + +function jsonResponse(data: unknown, status = 200, headers?: HeadersInit) { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +} + +async function verifyTurnstile( + secret: string, + token: string, + ip: string | null, +): Promise { + const body = new URLSearchParams({ secret, response: token }); + if (ip) body.set("remoteip", ip); + const response = await fetch( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + { method: "POST", body }, + ); + if (!response.ok) return false; + const data = (await response.json()) as { success?: boolean }; + return data.success === true; +} + +async function fetchDataforseoResult( + path: string, + payload: Record, + apiKey: string, +) { + const response = await fetch(`${DATAFORSEO_BASE}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${apiKey}`, + }, + body: JSON.stringify([payload]), + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + throw new Error(`DataForSEO HTTP ${response.status} on ${path}`); + } + const task = taskEnvelopeSchema.parse(await response.json()).tasks?.[0]; + if (!task || task.status_code !== 20000) { + throw new Error( + `DataForSEO task ${task?.status_code ?? "missing"} on ${path}: ${task?.status_message ?? "no task"}`, + ); + } + return task.result?.[0] ?? null; +} + +export const Route = createFileRoute("/api/backlink-check")({ + server: { + handlers: { + POST: async ({ request }) => { + const body = await request.json().catch(() => null); + const parsed = requestSchema.safeParse(body); + if (!parsed.success) { + return jsonResponse( + { error: parsed.error.issues[0]?.message ?? "Invalid request" }, + 400, + ); + } + + const domain = normalizeDomain(parsed.data.target); + if (!domain) { + return jsonResponse( + { error: "Enter a valid domain, like example.com" }, + 400, + ); + } + + const apiKey = (env as any).DATAFORSEO_API_KEY as string | undefined; + if (!apiKey) { + console.error("Missing DATAFORSEO_API_KEY"); + return jsonResponse( + { error: "Service temporarily unavailable" }, + 503, + ); + } + + const ip = request.headers.get("cf-connecting-ip"); + + // Bot check. Enforced only when the secret is configured, so local + // dev and fresh deploys keep working without a Turnstile widget. + const turnstileSecret = (env as any).TURNSTILE_SECRET_KEY as + | string + | undefined; + if (turnstileSecret) { + const token = parsed.data.turnstileToken; + const verified = token + ? await verifyTurnstile(turnstileSecret, token, ip) + : false; + if (!verified) { + console.error( + token + ? "Turnstile siteverify rejected the token — VITE_TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY may be from different widgets" + : "TURNSTILE_SECRET_KEY is set but the request sent no token — VITE_TURNSTILE_SITE_KEY may be missing from the deployed build", + ); + return jsonResponse( + { + error: + "Human verification failed. Refresh the page and try again.", + }, + 403, + ); + } + } + + const limiter = (env as any).BACKLINK_CHECK_RATE_LIMIT as + | RateLimiter + | undefined; + if (limiter) { + const { success } = await limiter.limit({ key: ip ?? "unknown" }); + if (!success) { + return jsonResponse( + { error: "Too many checks. Try again in a minute." }, + 429, + ); + } + } + + // Per-colo cache so repeat checks of the same domain don't re-bill. + const cache = (caches as unknown as { default: Cache }).default; + const cacheKey = new Request( + `https://openseo.so/api/backlink-check/${domain}`, + ); + const cached = await cache.match(cacheKey); + if (cached) return cached; + + // Global daily budget. Best-effort: KV reads are edge-cached and the + // increment is non-atomic, so the ceiling is approximate — the hard + // spend bound is the DataForSEO account balance. Counts attempts, not + // successes, so charged-but-failed calls still consume budget, and a + // KV error can never fail a request the user already paid latency for. + const kv = (env as any).BACKLINK_CHECK_KV as KvStore | undefined; + if (kv) { + const budgetKey = `daily-checks:${new Date().toISOString().slice(0, 10)}`; + try { + const stored = Number(await kv.get(budgetKey)); + const used = Number.isFinite(stored) ? stored : 0; + if (used >= DAILY_CHECK_BUDGET) { + return jsonResponse( + { + error: + "The free checker has reached today's limit. Try again tomorrow, or sign up for OpenSEO for full backlink research.", + }, + 429, + ); + } + await kv.put(budgetKey, String(used + 1), { + expirationTtl: 2 * 86_400, + }); + } catch (err) { + console.error("Backlink check budget counter error:", err); + } + } + + // Mirrors the app's backlinks defaults (src/server/lib/dataforseo/backlinks.ts). + const commonPayload = { + target: domain, + include_subdomains: true, + include_indirect_links: true, + exclude_internal_backlinks: true, + backlinks_status_type: "live", + rank_scale: "one_hundred", + }; + + try { + const [summaryRaw, backlinksRaw] = await Promise.all([ + fetchDataforseoResult( + "/v3/backlinks/summary/live", + commonPayload, + apiKey, + ), + fetchDataforseoResult( + "/v3/backlinks/backlinks/live", + { + ...commonPayload, + limit: TOP_BACKLINKS_LIMIT, + mode: "one_per_domain", + // Strongest linking domains first, like Ahrefs' free checker. + order_by: ["domain_from_rank,desc"], + }, + apiKey, + ), + ]); + + const summary = summaryResultSchema.parse(summaryRaw ?? {}); + const backlinks = backlinksResultSchema.parse(backlinksRaw ?? {}); + + const topBacklinks = (backlinks.items ?? []) + .filter((item) => item.type === "backlink" && item.url_from) + .map((item) => ({ + domainFrom: item.domain_from ?? null, + urlFrom: item.url_from ?? null, + urlTo: item.url_to ?? null, + pageTitle: item.page_from_title?.trim() + ? item.page_from_title + : null, + anchor: item.anchor?.trim() ? item.anchor : null, + dofollow: item.dofollow ?? null, + domainRank: item.domain_from_rank ?? null, + })); + + const response = jsonResponse( + { + target: domain, + summary: { + rank: summary.rank ?? null, + backlinks: summary.backlinks ?? null, + referringDomains: summary.referring_domains ?? null, + brokenBacklinks: summary.broken_backlinks ?? null, + }, + topBacklinks, + }, + 200, + { "Cache-Control": `public, max-age=${CACHE_TTL_SECONDS}` }, + ); + await cache.put(cacheKey, response.clone()); + return response; + } catch (err) { + console.error("Backlink check error:", err); + // Negative cache: money was already spent, so stop an immediate + // retry loop on a reliably-failing domain. 502s are only stored + // when explicitly marked cacheable. + const response = jsonResponse( + { error: "Backlink check failed. Please try again." }, + 502, + { "Cache-Control": "public, max-age=120" }, + ); + await cache.put(cacheKey, response.clone()).catch(() => {}); + return response; + } + }, + }, + }, +}); diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 03ded05..e06f738 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -16,7 +16,12 @@ body { background: var(--color-fd-background); color: var(--color-fd-foreground); font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, - "Segoe UI", sans-serif; + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; overflow-x: clip; } diff --git a/web/wrangler.jsonc b/web/wrangler.jsonc index 8974fdb..9607303 100644 --- a/web/wrangler.jsonc +++ b/web/wrangler.jsonc @@ -27,11 +27,40 @@ "custom_domain": true, }, ], + "kv_namespaces": [ + { + "binding": "BACKLINK_CHECK_KV", + "id": "b3a051a75a7b478e93b46cdaf6da3572", + }, + ], + "ratelimits": [ + { + "name": "BACKLINK_CHECK_RATE_LIMIT", + "namespace_id": "1001", + "simple": { "limit": 5, "period": 60 }, + }, + ], "env": { + // Bindings are not inherited by named envs; keep preview's copies in sync. + // Shared KV id is deliberate: one global daily budget across both workers. "preview": { "name": "open-seo-landing-preview", "workers_dev": true, "preview_urls": true, + "routes": [], + "kv_namespaces": [ + { + "binding": "BACKLINK_CHECK_KV", + "id": "b3a051a75a7b478e93b46cdaf6da3572", + }, + ], + "ratelimits": [ + { + "name": "BACKLINK_CHECK_RATE_LIMIT", + "namespace_id": "1001", + "simple": { "limit": 5, "period": 60 }, + }, + ], }, }, }