website: Create website (#19)

* Add website

* save

* chore: validate website in CI and fix workers typing
This commit is contained in:
Ben Senescu 2026-03-11 23:23:47 -04:00 committed by GitHub
parent 75f9868500
commit a0ef7412f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 7118 additions and 12 deletions

View File

@ -36,3 +36,12 @@ jobs:
- name: Run CI checks - name: Run CI checks
run: pnpm run ci:check run: pnpm run ci:check
- name: Install website dependencies
run: pnpm --dir web install --frozen-lockfile
- name: Run website type checks
run: pnpm --dir web run types:check
- name: Build website
run: pnpm --dir web run build

View File

@ -9,6 +9,7 @@
"node_modules", "node_modules",
"dist", "dist",
".output", ".output",
"web",
"src/routeTree.gen.ts", "src/routeTree.gen.ts",
"worker-configuration.d.ts" "worker-configuration.d.ts"
], ],

View File

@ -7,3 +7,4 @@ dist/
drizzle/ drizzle/
planning/ planning/
worker-configuration.d.ts worker-configuration.d.ts
web/

View File

@ -1,5 +1,4 @@
{ {
"ignoreBinaries": ["portless"],
"entry": [ "entry": [
// Detect Tanstack Start Routes // Detect Tanstack Start Routes
"src/router.tsx", "src/router.tsx",
@ -9,29 +8,20 @@
// DB index re-exports schema for convenience // DB index re-exports schema for convenience
"src/db/index.ts", "src/db/index.ts",
], ],
"project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts"], "project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"],
"ignore": [ "ignore": [
"drizzle-prod.config.ts", "drizzle-prod.config.ts",
"src/client/features/keywords/utils.ts", "src/client/features/keywords/utils.ts",
"src/client/hooks/useDomainSearchHistory.ts",
"src/client/hooks/useSearchHistory.ts", "src/client/hooks/useSearchHistory.ts",
"src/server.ts", "src/server.ts",
"src/server/lib/audit/progress-kv.ts", "src/server/lib/audit/progress-kv.ts",
"src/server/lib/audit/types.ts", "src/server/lib/audit/types.ts",
"src/server/services/PsiIssuesService.ts",
"src/server/workflows/SiteAuditWorkflow.ts", "src/server/workflows/SiteAuditWorkflow.ts",
"src/serverFunctions/keywords.ts", "src/serverFunctions/keywords.ts",
"src/serverFunctions/psi.ts", "src/serverFunctions/psi.ts",
"src/types/schemas/audit.ts", "src/types/schemas/audit.ts",
"src/types/schemas/psi.ts", "src/types/schemas/psi.ts",
], ],
"ignoreFiles": [
"src/server/services/keyword-research/helpers.ts",
"src/server/services/keyword-research/projects.ts",
"src/server/services/keyword-research/research-data.ts",
"src/server/services/keyword-research/saved-keywords.ts",
"src/server/services/keyword-research/serp.ts",
],
// Disable Drizzle plugin - it tries to load drizzle.config.ts which imports cloudflare:workers // Disable Drizzle plugin - it tries to load drizzle.config.ts which imports cloudflare:workers
"drizzle": false, "drizzle": false,
"ignoreDependencies": [ "ignoreDependencies": [
@ -40,6 +30,5 @@
"daisyui", "daisyui",
"@tanstack/query-sync-storage-persister", "@tanstack/query-sync-storage-persister",
"@tanstack/react-query-persist-client", "@tanstack/react-query-persist-client",
"portless",
], ],
} }

View File

@ -1,5 +1,6 @@
{ {
"include": ["**/*.ts", "**/*.tsx"], "include": ["**/*.ts", "**/*.tsx"],
"exclude": ["web/**/*"],
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,

9
web/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules
dist
.wrangler
.vinxi
.output
.vercel
.source
source.generated.ts
*.local

9
web/.prettierignore Normal file
View File

@ -0,0 +1,9 @@
node_modules
dist
.wrangler
.vinxi
.output
.source
source.generated.ts
pnpm-lock.yaml
routeTree.gen.ts

1
web/.worktreeinclude Normal file
View File

@ -0,0 +1 @@
web/open-rank

47
web/package.json Normal file
View File

@ -0,0 +1,47 @@
{
"name": "open-rank-landing",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"dev": "vite dev",
"build": "vite build && node scripts/generate-sitemap.js",
"build:app": "vite build",
"sitemap": "node scripts/generate-sitemap.js",
"start": "wrangler dev",
"preview": "vite preview",
"types:check": "fumadocs-mdx && tsc --noEmit",
"format:check": "prettier --check .",
"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"
},
"dependencies": {
"@tanstack/react-router": "^1.161.3",
"@tanstack/react-router-devtools": "^1.161.3",
"@tanstack/react-start": "^1.161.3",
"fumadocs-core": "^15.5.1",
"fumadocs-mdx": "^11.6.5",
"fumadocs-ui": "^15.5.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"vite": "^7.3.1",
"zod": "^3.24.0"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.13.3",
"@tailwindcss/vite": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "^22.10.2",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.3",
"prettier": "^3.6.2",
"srvx": "^0.11.2",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^6.0.5",
"wrangler": "^4.67.0"
}
}

5868
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
web/public/demo-poster.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
web/public/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

BIN
web/public/demo.mp4 Normal file

Binary file not shown.

BIN
web/public/demo.webm Normal file

Binary file not shown.

4
web/public/robots.txt Normal file
View File

@ -0,0 +1,4 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
Sitemap: https://openrank.io/sitemap.xml

View File

@ -0,0 +1,52 @@
#!/usr/bin/env node
import { existsSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const DIST_DIR = join(__dirname, "../dist/client");
const DEFAULT_SITE_URL = "https://openrank.io";
const SITE_URL = (process.env.SITE_URL ?? DEFAULT_SITE_URL).replace(/\/+$/, "");
const STATIC_PATHS = new Set(["/", "/privacy"]);
function toCanonicalUrl(path) {
if (path === "/") {
return `${SITE_URL}/`;
}
return `${SITE_URL}${path.replace(/\/+$/, "")}`;
}
function main() {
if (!existsSync(DIST_DIR) || !statSync(DIST_DIR).isDirectory()) {
throw new Error(`Build output directory does not exist: ${DIST_DIR}`);
}
const urlPaths = new Set(STATIC_PATHS);
const urls = Array.from(urlPaths)
.map((path) => toCanonicalUrl(path))
.sort((a, b) => a.localeCompare(b));
const lastmod = new Date().toISOString();
const sitemapBody = urls
.map(
(url) =>
` <url>\n <loc>${url}</loc>\n <lastmod>${lastmod}</lastmod>\n </url>`,
)
.join("\n");
const sitemapXml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${sitemapBody}\n</urlset>\n`;
const sitemapPath = join(DIST_DIR, "sitemap.xml");
writeFileSync(sitemapPath, sitemapXml);
console.log(`Generated sitemap with ${urls.length} URLs at ${sitemapPath}`);
}
main();

17
web/source.config.ts Normal file
View File

@ -0,0 +1,17 @@
import {
defineConfig,
defineCollections,
frontmatterSchema,
} from "fumadocs-mdx/config/zod-3";
import { z } from "zod";
export const blog = defineCollections({
type: "doc",
dir: "content/blog",
schema: (frontmatterSchema as any).extend({
author: z.string(),
date: z.string(),
}),
});
export default defineConfig();

View File

@ -0,0 +1,24 @@
import { Link } from "@tanstack/react-router";
import { HomeLayout } from "fumadocs-ui/layouts/home";
import { baseOptions } from "@/lib/layout.shared";
export function NotFound() {
return (
<HomeLayout {...baseOptions()} className="text-center py-32 justify-center">
<div className="flex flex-col items-center gap-4">
<h1 className="text-6xl font-bold text-fd-muted-foreground">404</h1>
<h2 className="text-2xl font-semibold">Page Not Found</h2>
<p className="text-fd-muted-foreground max-w-md">
The page you are looking for might have been removed, had its name
changed, or is temporarily unavailable.
</p>
<Link
to="/"
className="mt-4 px-4 py-2 rounded-lg bg-fd-primary text-fd-primary-foreground font-medium text-sm hover:opacity-90 transition-opacity"
>
Back to Home
</Link>
</div>
</HomeLayout>
);
}

View File

@ -0,0 +1,29 @@
import { notFound } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { blogSource } from "@/lib/source";
export const getBlogPost = createServerFn({ method: "GET" })
.inputValidator((slugs: string[]) => slugs)
.handler(async ({ data: slugs }) => {
const page = blogSource.getPage(slugs);
if (!page) throw notFound();
return {
path: page.path,
title: page.data.title,
description: page.data.description,
url: page.url,
};
});
export const getBlogPosts = createServerFn({ method: "GET" }).handler(
async () => {
const pages = blogSource.getPages();
return pages.map((page) => ({
title: page.data.title,
description: page.data.description,
url: page.url,
slugs: page.slugs,
}));
},
);

View File

@ -0,0 +1,16 @@
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: <span className="font-semibold">OpenRank</span>,
},
links: [
{
text: "GitHub",
url: "https://github.com/every-app/open-seo",
external: true,
},
],
};
}

51
web/src/lib/seo.ts Normal file
View File

@ -0,0 +1,51 @@
const DEFAULT_SITE_URL = "https://openrank.io";
export const SITE_URL = (
process.env.SITE_URL ??
process.env.VITE_SITE_URL ??
DEFAULT_SITE_URL
).replace(/\/+$/, "");
export function toCanonicalPath(path: string): string {
if (!path || path === "/") return "/";
const normalized = path.startsWith("/") ? path : `/${path}`;
return normalized.replace(/\/+$/, "");
}
export function toCanonicalUrl(path: string): string {
return new URL(toCanonicalPath(path), `${SITE_URL}/`).href;
}
type BuildSeoParams = {
title: string;
path: string;
description?: string;
titleSuffix?: string;
ogType?: "website" | "article";
};
export function buildPageSeo({
title,
path,
description,
titleSuffix,
ogType = "website",
}: BuildSeoParams) {
const fullTitle = titleSuffix ? `${title} - ${titleSuffix}` : title;
const canonicalUrl = toCanonicalUrl(path);
return {
meta: [
{ title: fullTitle },
...(description ? [{ name: "description", content: description }] : []),
{ property: "og:type", content: ogType },
{ property: "og:title", content: fullTitle },
...(description
? [{ property: "og:description", content: description }]
: []),
{ property: "og:url", content: canonicalUrl },
],
links: [{ rel: "canonical", href: canonicalUrl }],
};
}

15
web/src/lib/source.ts Normal file
View File

@ -0,0 +1,15 @@
import { loader } from "fumadocs-core/source";
// Keep this deep import for now: the public "fumadocs-mdx/runtime/vite" entry
// resolves to the browser runtime in our SSR build, which breaks sourceAsync.
import { fromConfig } from "../../node_modules/fumadocs-mdx/dist/runtime/vite/server.js";
import { blog } from "../../source.generated";
import type * as Config from "../../source.config";
const serverCreate = fromConfig<typeof Config>();
export const blogSource = loader({
source: await serverCreate.sourceAsync(blog, {} as Record<string, never>),
baseUrl: "/blogs",
});
export { blog };

198
web/src/routeTree.gen.ts Normal file
View File

@ -0,0 +1,198 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as PrivacyRouteImport } from './routes/privacy'
import { Route as IndexRouteImport } from './routes/index'
import { Route as BlogsIndexRouteImport } from './routes/blogs/index'
import { Route as JsScriptDotjsRouteImport } from './routes/js/script[.]js'
import { Route as BlogsSplatRouteImport } from './routes/blogs/$'
import { Route as ApiSubscribeRouteImport } from './routes/api/subscribe'
import { Route as ApiEventRouteImport } from './routes/api/event'
const PrivacyRoute = PrivacyRouteImport.update({
id: '/privacy',
path: '/privacy',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const BlogsIndexRoute = BlogsIndexRouteImport.update({
id: '/blogs/',
path: '/blogs/',
getParentRoute: () => rootRouteImport,
} as any)
const JsScriptDotjsRoute = JsScriptDotjsRouteImport.update({
id: '/js/script.js',
path: '/js/script.js',
getParentRoute: () => rootRouteImport,
} as any)
const BlogsSplatRoute = BlogsSplatRouteImport.update({
id: '/blogs/$',
path: '/blogs/$',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSubscribeRoute = ApiSubscribeRouteImport.update({
id: '/api/subscribe',
path: '/api/subscribe',
getParentRoute: () => rootRouteImport,
} as any)
const ApiEventRoute = ApiEventRouteImport.update({
id: '/api/event',
path: '/api/event',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/privacy': typeof PrivacyRoute
'/api/event': typeof ApiEventRoute
'/api/subscribe': typeof ApiSubscribeRoute
'/blogs/$': typeof BlogsSplatRoute
'/js/script.js': typeof JsScriptDotjsRoute
'/blogs/': typeof BlogsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/privacy': typeof PrivacyRoute
'/api/event': typeof ApiEventRoute
'/api/subscribe': typeof ApiSubscribeRoute
'/blogs/$': typeof BlogsSplatRoute
'/js/script.js': typeof JsScriptDotjsRoute
'/blogs': typeof BlogsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/privacy': typeof PrivacyRoute
'/api/event': typeof ApiEventRoute
'/api/subscribe': typeof ApiSubscribeRoute
'/blogs/$': typeof BlogsSplatRoute
'/js/script.js': typeof JsScriptDotjsRoute
'/blogs/': typeof BlogsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/privacy'
| '/api/event'
| '/api/subscribe'
| '/blogs/$'
| '/js/script.js'
| '/blogs/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/privacy'
| '/api/event'
| '/api/subscribe'
| '/blogs/$'
| '/js/script.js'
| '/blogs'
id:
| '__root__'
| '/'
| '/privacy'
| '/api/event'
| '/api/subscribe'
| '/blogs/$'
| '/js/script.js'
| '/blogs/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
PrivacyRoute: typeof PrivacyRoute
ApiEventRoute: typeof ApiEventRoute
ApiSubscribeRoute: typeof ApiSubscribeRoute
BlogsSplatRoute: typeof BlogsSplatRoute
JsScriptDotjsRoute: typeof JsScriptDotjsRoute
BlogsIndexRoute: typeof BlogsIndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/privacy': {
id: '/privacy'
path: '/privacy'
fullPath: '/privacy'
preLoaderRoute: typeof PrivacyRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/blogs/': {
id: '/blogs/'
path: '/blogs'
fullPath: '/blogs/'
preLoaderRoute: typeof BlogsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/js/script.js': {
id: '/js/script.js'
path: '/js/script.js'
fullPath: '/js/script.js'
preLoaderRoute: typeof JsScriptDotjsRouteImport
parentRoute: typeof rootRouteImport
}
'/blogs/$': {
id: '/blogs/$'
path: '/blogs/$'
fullPath: '/blogs/$'
preLoaderRoute: typeof BlogsSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/subscribe': {
id: '/api/subscribe'
path: '/api/subscribe'
fullPath: '/api/subscribe'
preLoaderRoute: typeof ApiSubscribeRouteImport
parentRoute: typeof rootRouteImport
}
'/api/event': {
id: '/api/event'
path: '/api/event'
fullPath: '/api/event'
preLoaderRoute: typeof ApiEventRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
PrivacyRoute: PrivacyRoute,
ApiEventRoute: ApiEventRoute,
ApiSubscribeRoute: ApiSubscribeRoute,
BlogsSplatRoute: BlogsSplatRoute,
JsScriptDotjsRoute: JsScriptDotjsRoute,
BlogsIndexRoute: BlogsIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

12
web/src/router.tsx Normal file
View File

@ -0,0 +1,12 @@
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import { NotFound } from "@/components/not-found";
export function getRouter() {
return createTanStackRouter({
routeTree,
defaultPreload: "intent",
scrollRestoration: true,
defaultNotFoundComponent: NotFound,
});
}

56
web/src/routes/__root.tsx Normal file
View File

@ -0,0 +1,56 @@
import {
createRootRoute,
HeadContent,
Outlet,
Scripts,
} from "@tanstack/react-router";
import * as React from "react";
import appCss from "@/styles/app.css?url";
import { RootProvider } from "fumadocs-ui/provider/tanstack";
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: "utf-8",
},
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
],
links: [
{ rel: "stylesheet", href: appCss },
{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" },
],
}),
component: RootComponent,
});
function RootComponent() {
return (
<RootDocument>
<Outlet />
</RootDocument>
);
}
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<HeadContent />
<script
dangerouslySetInnerHTML={{
__html:
"(function(){function loadAnalytics(){if(window.__openrankAnalyticsLoaded)return;window.__openrankAnalyticsLoaded=true;window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)};plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init({endpoint:'/api/event'});var script=document.createElement('script');script.defer=true;script.src='/js/script.js';document.head.appendChild(script)}function schedule(){if('requestIdleCallback'in window){window.requestIdleCallback(loadAnalytics,{timeout:2000});return}window.setTimeout(loadAnalytics,2000)}if(document.readyState==='complete'){schedule();return}window.addEventListener('load',schedule,{once:true})})();",
}}
/>
</head>
<body className="flex flex-col min-h-screen bg-white">
<RootProvider>{children}</RootProvider>
<Scripts />
</body>
</html>
);
}

View File

@ -0,0 +1,20 @@
import { createFileRoute } from "@tanstack/react-router";
const PLAUSIBLE_EVENT_URL = "https://plausible.io/api/event";
export const Route = createFileRoute("/api/event")({
server: {
handlers: {
POST: async ({ request }) => {
const proxyRequest = new Request(PLAUSIBLE_EVENT_URL, request);
proxyRequest.headers.delete("cookie");
const upstreamResponse = await fetch(proxyRequest);
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
headers: upstreamResponse.headers,
});
},
},
},
});

View File

@ -0,0 +1,86 @@
import { createFileRoute } from "@tanstack/react-router";
import { env } from "cloudflare:workers";
import { z } from "zod";
const subscribeSchema = z.object({
email: z.string().email("Please enter a valid email address"),
});
export const Route = createFileRoute("/api/subscribe")({
server: {
handlers: {
POST: async ({ request }) => {
const body = await request.json();
const parsed = subscribeSchema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify({ error: parsed.error.issues[0]?.message }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const loopsApiKey = (env as any).LOOPS_API_KEY as string | undefined;
if (!loopsApiKey) {
console.error("Missing LOOPS_API_KEY");
return new Response(
JSON.stringify({ error: "Service temporarily unavailable" }),
{ status: 503, headers: { "Content-Type": "application/json" } },
);
}
try {
const loopsResponse = await fetch(
"https://app.loops.so/api/v1/contacts/create",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${loopsApiKey}`,
},
body: JSON.stringify({
email: parsed.data.email,
source: "openrank-waitlist",
}),
},
);
if (loopsResponse.status === 409) {
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (!loopsResponse.ok) {
const loopsError = await loopsResponse.json().catch(() => null);
console.error("Loops contact creation error:", loopsError);
return new Response(
JSON.stringify({
error: "Failed to subscribe. Please try again.",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err) {
console.error("Subscribe endpoint error:", err);
return new Response(
JSON.stringify({
error: "Failed to subscribe. Please try again.",
}),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
},
},
},
});

View File

@ -0,0 +1,76 @@
import { createFileRoute } from "@tanstack/react-router";
import { HomeLayout } from "fumadocs-ui/layouts/home";
import { createClientLoader } from "fumadocs-mdx/runtime/vite";
import { DocsBody } from "fumadocs-ui/page";
import defaultMdxComponents from "fumadocs-ui/mdx";
import { baseOptions } from "@/lib/layout.shared";
import { Suspense } from "react";
import { getBlogPost } from "@/lib/content.functions";
import { blog } from "../../../source.generated";
import { buildPageSeo } from "@/lib/seo";
export const Route = createFileRoute("/blogs/$")({
loader: async ({ params }: { params: { _splat?: string } }) => {
const slugs = params._splat?.split("/") ?? [];
const data = await getBlogPost({ data: slugs });
await clientMdxLoader.preload(data.path);
return data;
},
head: ({ loaderData }: { loaderData?: unknown }) => {
const data = loaderData as
| { title?: string; description?: string; url?: string }
| undefined;
const title = data?.title ?? "OpenRank Blog";
const description = data?.description;
return buildPageSeo({
title,
description,
path: data?.url ?? "/blogs",
titleSuffix: "OpenRank Blog",
ogType: "article",
});
},
component: BlogPost,
});
const clientMdxLoader = createClientLoader(blog, {
id: "blog",
component({ default: MDX }) {
return (
<DocsBody>
<MDX
components={{
...defaultMdxComponents,
}}
/>
</DocsBody>
);
},
});
function BlogPost() {
const data = Route.useLoaderData() as {
path: string;
title: string;
description?: string;
};
const Content = clientMdxLoader.getComponent(data.path);
return (
<HomeLayout {...baseOptions()}>
<article className="max-w-3xl mx-auto px-6 py-12 md:py-24">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{data.title}</h1>
{data.description && (
<p className="text-lg text-fd-muted-foreground">
{data.description}
</p>
)}
</header>
<Suspense>
<Content />
</Suspense>
</article>
</HomeLayout>
);
}

View File

@ -0,0 +1,58 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { HomeLayout } from "fumadocs-ui/layouts/home";
import { baseOptions } from "@/lib/layout.shared";
import { getBlogPosts } from "@/lib/content.functions";
import { buildPageSeo } from "@/lib/seo";
const blogIndexDescription =
"Updates and guides from OpenRank.";
export const Route = createFileRoute("/blogs/")({
head: () =>
buildPageSeo({
title: "OpenRank Blog",
description: blogIndexDescription,
path: "/blogs",
}),
component: BlogIndex,
loader: async () => await getBlogPosts(),
});
function BlogIndex() {
const posts = Route.useLoaderData();
return (
<HomeLayout {...baseOptions()}>
<div className="max-w-3xl mx-auto px-6 py-12 md:py-24">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
{posts.length === 0 ? (
<p className="text-fd-muted-foreground">
No blog posts yet. Check back soon.
</p>
) : (
<div className="space-y-8">
{posts.map((post) => (
<article key={post.url} className="border-b pb-8 last:border-b-0">
<Link
to="/blogs/$"
params={{ _splat: post.slugs.join("/") }}
className="group"
>
<h2 className="text-2xl font-semibold group-hover:text-fd-primary transition-colors mb-2">
{post.title}
</h2>
{post.description && (
<p className="text-fd-muted-foreground">
{post.description}
</p>
)}
</Link>
</article>
))}
</div>
)}
</div>
</HomeLayout>
);
}

248
web/src/routes/index.tsx Normal file
View File

@ -0,0 +1,248 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import { toCanonicalUrl } from "@/lib/seo";
const homeTitle = "OpenRank - Own Your SEO";
const homeDescription =
"Own your SEO. Pay only for what you use. No subscriptions. Open source alternative to Semrush and Ahrefs.";
export const Route = createFileRoute("/")({
head: () => ({
meta: [
{ title: homeTitle },
{ name: "description", content: homeDescription },
{ property: "og:type", content: "website" },
{ property: "og:title", content: homeTitle },
{ property: "og:description", content: homeDescription },
{ property: "og:url", content: toCanonicalUrl("/") },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: homeTitle },
{ name: "twitter:description", content: homeDescription },
],
links: [{ rel: "canonical", href: toCanonicalUrl("/") }],
}),
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 };
}
function FooterLinks({ className }: { className?: string }) {
return (
<div className={`flex items-center gap-6 ${className || ""}`}>
<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>
<Link to="/privacy">Privacy</Link>
</div>
);
}
// ─── 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">OpenRank</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",
"Competitor Domain Insights",
"Site Audits",
"Backlinks, 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="OpenRank product demo"
>
<source src="/demo.webm" type="video/webm" />
<source src="/demo.mp4" type="video/mp4" />
<img
src="/demo-poster.webp"
alt="OpenRank 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 OpenRank
</p>
</div>
{/* Footer */}
<div className="mt-8 pt-8 border-t border-neutral-200">
<FooterLinks className="text-xs text-neutral-600 [&_a]:hover:text-neutral-900 [&_a]:transition-colors" />
</div>
</div>
</main>
);
}

View File

@ -0,0 +1,30 @@
import { createFileRoute } from "@tanstack/react-router";
const PLAUSIBLE_SCRIPT_URL = "https://plausible.io/js/pa-DllmchvGzcNdY2jEy1-Hc.js";
export const Route = createFileRoute("/js/script.js")({
server: {
handlers: {
GET: async () => {
const upstreamResponse = await fetch(PLAUSIBLE_SCRIPT_URL);
if (!upstreamResponse.ok) {
return new Response("Failed to load analytics script", {
status: 502,
headers: {
"content-type": "text/plain; charset=utf-8",
},
});
}
const headers = new Headers(upstreamResponse.headers);
headers.set("cache-control", "public, max-age=86400, immutable");
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
headers,
});
},
},
},
});

View File

@ -0,0 +1,72 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { buildPageSeo } from "@/lib/seo";
export const Route = createFileRoute("/privacy")({
head: () =>
buildPageSeo({
title: "Privacy Policy",
description: "OpenRank privacy policy",
path: "/privacy",
titleSuffix: "OpenRank",
}),
component: Privacy,
});
function Privacy() {
return (
<div>
<header className="px-6 py-4 border-b border-zinc-200">
<Link to="/" className="font-semibold text-zinc-900 hover:opacity-80">
OpenRank
</Link>
</header>
<article className="max-w-3xl mx-auto px-6 py-12 md:py-24 prose prose-gray text-zinc-900 prose-headings:text-zinc-900 prose-p:text-zinc-700 prose-a:text-zinc-900">
<h1>Privacy Policy</h1>
<p className="text-fd-muted-foreground">Last updated: March 2026</p>
<h2>What we collect</h2>
<p>
When you sign up for our waitlist, we collect your email address. This
is stored securely via Loops.so, our email service provider.
</p>
<h2>How we use it</h2>
<p>
We use your email address to send launch announcements, product updates,
and related OpenRank marketing emails. You can unsubscribe at any time
using the unsubscribe link in any email. We do not sell your data or
share it with third parties beyond our service providers.
</p>
<h2>Website analytics</h2>
<p>
We use Plausible Analytics to understand aggregate website traffic and
usage patterns (for example, page views and referring sites). Plausible
is cookie-free and does not use cross-site tracking. We route analytics
requests through our own domain before forwarding them to Plausible.
</p>
<h2>Data storage</h2>
<p>
Your email address is stored in Loops.so's infrastructure. You can request
deletion of your data at any time by emailing{" "}
<a href="mailto:privacy@everyapp.dev">privacy@everyapp.dev</a>.
</p>
<h2>Self-hosted usage</h2>
<p>
If you self-host OpenRank, no data is sent to us. The self-hosted
version communicates directly with DataForSEO's APIs using your own
credentials.
</p>
<h2>Contact</h2>
<p>
Questions about this policy? Email{" "}
<a href="mailto:privacy@everyapp.dev">privacy@everyapp.dev</a>.
</p>
</article>
</div>
);
}

12
web/src/styles/app.css Normal file
View File

@ -0,0 +1,12 @@
@import "tailwindcss";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/preset.css";
@theme {
--color-brand: #1a1a1a;
--color-brand-muted: #6b7280;
--color-brand-accent: #2563eb;
--color-surface: #fafafa;
--color-surface-raised: #ffffff;
--color-border-subtle: #e5e7eb;
}

4
web/src/types/cloudflare-workers.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module "cloudflare:workers" {
const env: Env;
export { env };
}

24
web/tsconfig.json Normal file
View File

@ -0,0 +1,24 @@
{
"include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["vite/client"],
"isolatedModules": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"target": "ES2022",
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"fumadocs-mdx:collections/*": [".source/*"]
},
"noEmit": true
}
}

34
web/vite.config.ts Normal file
View File

@ -0,0 +1,34 @@
import react from "@vitejs/plugin-react";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import { defineConfig } from "vite";
import tsConfigPaths from "vite-tsconfig-paths";
import tailwindcss from "@tailwindcss/vite";
import mdx from "fumadocs-mdx/vite";
export default defineConfig({
server: {
port: 4322,
},
ssr: {
resolve: {
conditions: ["worker", "import", "module", "default"],
},
},
plugins: [
mdx(await import("./source.config")),
tailwindcss(),
tsConfigPaths({
projects: ["./tsconfig.json"],
}),
cloudflare({
viteEnvironment: { name: "ssr" },
}),
tanstackStart({
prerender: {
enabled: true,
},
}),
react(),
],
});

5
web/worker-configuration.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
interface Env {
RESEND_API_KEY: string;
RESEND_AUDIENCE_ID: string;
RESEND_SEGMENT_ID: string;
}

28
web/wrangler.jsonc Normal file
View File

@ -0,0 +1,28 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "open-rank-landing",
"compatibility_date": "2026-02-19",
"compatibility_flags": [
"nodejs_compat"
],
"main": "@tanstack/react-start/server-entry",
"assets": {
"directory": "./dist/client",
"html_handling": "drop-trailing-slash",
},
"workers_dev": true,
"preview_urls": true,
"routes": [
{
"pattern": "openrank.io",
"custom_domain": true,
},
],
"env": {
"preview": {
"name": "open-rank-landing-preview",
"workers_dev": true,
"preview_urls": true,
},
},
}