Move guides to blogs and add best open source tools post (#243)
This commit is contained in:
parent
1495660631
commit
93705f125d
74
web/content/blogs/best-open-source-seo-tools.md
Normal file
74
web/content/blogs/best-open-source-seo-tools.md
Normal file
@ -0,0 +1,74 @@
|
||||
---
|
||||
title: "The Best Open Source SEO Tools in 2026"
|
||||
description: "Open source SEO tools in 2026: OpenSEO, SerpBear, SEONaut, LibreCrawl, and SEOMachine — what each one does, what it costs to run, and how to self-host."
|
||||
author: "OpenSEO Team"
|
||||
date: "2026-06-05"
|
||||
---
|
||||
|
||||
There are a lot of open source SEO projects on GitHub, but only a handful are mature enough to rely on. This guide covers those, plus a few honorable mentions worth watching or testing.
|
||||
|
||||
In the past, open source SEO tools struggled because they lacked quality data. Beyond auditing, most SEO tasks have a cost and the projects in this guide aren't totally free. For example, rank tracking requires SERP results from around the world. Coming up with a content strategy means you need accurate search volumes and backlink indexes. The tools in this list rely on paid third-party services, but they still cost far less than the equivalent legacy SaaS tools.
|
||||
|
||||
Note: OpenSEO publishes this guide, and OpenSEO is one of the tools listed, as the project has 2,000+ stars on GitHub. We have tried to make this useful even if you never touch it. We love open source and think it's the future of SEO tools: [openseo.so/open-source-seo](/open-source-seo).
|
||||
|
||||
## The main tools at a glance
|
||||
|
||||
| Tool | Stars | What it does | Requirements | Self-hosting |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [OpenSEO](https://github.com/every-app/open-seo) | 2.1k | All-in-one: keyword research, rank tracking, backlinks, site audits, AI visibility | DataForSEO API key | Docker or Cloudflare Workers |
|
||||
| [SerpBear](https://github.com/towfiqi/serpbear) | 2.0k | Rank tracking | A SERP API provider | Docker |
|
||||
| [SEONaut](https://github.com/StJudeWasHere/seonaut) | 717 | Technical SEO and site audits | None | Docker |
|
||||
| [LibreCrawl](https://github.com/PhialsBasement/LibreCrawl) | 681 | Site crawling and SEO audits | None | Desktop or web |
|
||||
| [SEOMachine](https://github.com/TheCraigHewitt/seomachine) | 7.1k | SEO content writing inside Claude Code | Claude Code + Anthropic API key | Clone the repo, run in Claude Code |
|
||||
|
||||
*Star counts are updated monthly. Last updated June 5, 2026.*
|
||||
|
||||
## OpenSEO
|
||||
|
||||
OpenSEO is an open source alternative to Semrush and Ahrefs, built to be the only SEO tool that companies or small agencies need. It covers keyword research, rank tracking, backlinks, site audits, AI brand visibility, and an AI search prompt explorer. See them all on the [features page](/features).
|
||||
|
||||
It relies on [DataForSEO](https://dataforseo.com), a paid service, which is the gold standard for SEO data with pay-as-you-go pricing. Many features cost money to run, but far less than a legacy SaaS seat, and the [pricing is published in the repo](https://github.com/every-app/open-seo#seo-api-cost-reference).
|
||||
|
||||
OpenSEO also has an [MCP server](/docs/mcp) and AI skills, so you can use Claude Code, Codex, or OpenClaw to handle tedious SEO work like a first pass of keyword research, then dig into the results in the OpenSEO UI. That is different from pointing an agent at DataForSEO's MCP directly. You can save data to your OpenSEO account through the MCP, like tags for clustering keywords, and you can ask the agent for a link to view its research in OpenSEO instead of trusting whatever it reports back.
|
||||
|
||||
## SerpBear
|
||||
|
||||
[SerpBear](https://docs.serpbear.com)'s main focus is rank tracking. You can track unlimited keywords for unlimited domains.
|
||||
|
||||
The app is free; you pay by usage for the SERP requests you make to your chosen provider. SerpBear supports eight providers with different cost and functionality tradeoffs, so you can pick the one that fits. It also has a free Google Search Console integration and a free Keyword Research feature. Both pair well with rank tracking: you can see your real GSC data and find new keywords to target.
|
||||
|
||||
One warning on Keyword Research. It relies on the [Google Ads API](https://docs.serpbear.com/miscellaneous/integrate-google-ads), which has an application and approval process before you can use it.
|
||||
|
||||
## SEONaut & LibreCrawl
|
||||
|
||||
SEONaut and LibreCrawl are both open source Screaming Frog alternatives for technical SEO: crawl a site, audit on-page SEO, and surface issues like broken links. Neither depends on a paid data provider, so they are the cheapest tools here to run, and an easy first install if technical SEO is your immediate need.
|
||||
|
||||
The difference is how you run them. SEONaut is a self-hosted web app (Docker), with a hosted version and the full feature list at [seonaut.org/features](https://seonaut.org/features/). [LibreCrawl](https://librecrawl.com) runs as a free desktop or web crawler that analyzes links and exports SEO data; it is newer but moving fast. Try whichever fits your setup.
|
||||
|
||||
## SEOMachine
|
||||
|
||||
SEOMachine is a Claude Code workspace for creating long-form, SEO-optimized blog content, rather than a data platform. It works with Claude Code and an Anthropic API account, plus optional Google Analytics, Search Console, and DataForSEO integrations. To self-host, clone the repo, install its Python dependencies, add your business context, and open it in Claude Code.
|
||||
|
||||
It gives you commands like /research, /write, and /optimize, along with a set of SEO agents to draft and refine content. If your bottleneck is producing content rather than pulling data, it solves a different problem than the tools above.
|
||||
|
||||
## Honorable mentions
|
||||
|
||||
These have fewer stars, or fill a narrower niche, than the main tools above. We have not put any of them through real work, so treat these as leads rather than recommendations. If you love one of them, email us and we will test it. Sorted by GitHub stars.
|
||||
|
||||
- [openserp](https://github.com/karust/openserp) (745, active): a Go API and CLI that scrapes normalized SERP results from Google, Yandex, Baidu, Bing, DuckDuckGo, and Ecosia, self-hostable via Docker.
|
||||
- [RustySEO](https://github.com/mascanho/RustySEO) (276, active): a cross-platform desktop SEO/GEO toolkit with a Rust crawling core, plus Google Analytics, Search Console, and PageSpeed integrations. It looked promising, but the Mac build would not run on our machine.
|
||||
- [Greenflare](https://github.com/beb7/gflare-tk) (195, unmaintained): a lightweight Python technical-SEO crawler for Linux, Mac, and Windows. It is no longer maintained — last release 2021 — and its download site is down.
|
||||
- [contentswift](https://github.com/hilmanski/contentswift) (159, unmaintained): a self-hostable content research tool that analyzes top-ranking SERP results to guide on-page optimization. The demo video is strong, but the repo has been dormant since 2023 and has no declared license.
|
||||
- [SEO Panel](https://github.com/seopanel/Seo-Panel) (146, active): an older PHP control panel, around since 2010, for managing SEO across multiple sites — rank tracking, audits, sitemaps, backlink monitoring, and multi-user accounts.
|
||||
- [elmo](https://github.com/elmohq/elmo) (124, active): an AI-visibility (AEO/GEO) tracker that monitors how ChatGPT, Claude, Gemini, and Perplexity mention a brand and cite its content, self-hostable via Docker Compose.
|
||||
- [FreeCrawl-SEO-Tool](https://github.com/kemalai/FreeCrawl-SEO-Tool) (46, active): a free desktop SEO crawler aimed at large technical audits — 1M+ URLs, 150+ checks, JS rendering — that runs locally with no telemetry. Very new.
|
||||
- [seo-tools-api](https://github.com/oguzhan18/seo-tools-api) (46, unmaintained): a NestJS REST API bundling meta-tag analysis, sitemap generation, SEO scoring, and rank and backlink checks. No declared license.
|
||||
- [google-search-console-export-all](https://github.com/swalker-888/google-search-console-export-all) (8, unmaintained): a single-file Node.js script that bulk-exports all your Search Console data to CSV, bypassing the UI's row limits. No declared license.
|
||||
|
||||
One clarification, since it shows up on other lists: seojuice.com is not open source. They publish open source SDKs for their APIs, but the core product is closed.
|
||||
|
||||
## Try OpenSEO, and tell us what we missed
|
||||
|
||||
If you want one open source tool that covers most of SEO, start with OpenSEO. Self-host it with Docker or Cloudflare Workers, or use the hosted version at [openseo.so](https://openseo.so) if you would rather not run it yourself.
|
||||
|
||||
We will keep this guide current. If there is an open source SEO project you love that we did not cover, email us at ben@openseo.so and we will test it and consider adding it.
|
||||
@ -9,7 +9,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const DIST_DIR = join(__dirname, "../dist/client");
|
||||
const GUIDE_CONTENT_DIR = join(__dirname, "../content/guides");
|
||||
const BLOG_CONTENT_DIR = join(__dirname, "../content/blogs");
|
||||
const DOCS_CONTENT_DIR = join(__dirname, "../content/docs");
|
||||
|
||||
const DEFAULT_SITE_URL = "https://openseo.so";
|
||||
@ -20,7 +20,7 @@ const STATIC_PATHS = [
|
||||
"/pricing",
|
||||
"/privacy",
|
||||
"/terms-and-conditions",
|
||||
"/guides",
|
||||
"/blogs",
|
||||
"/docs",
|
||||
"/features",
|
||||
"/features/mcp",
|
||||
@ -90,7 +90,7 @@ function main() {
|
||||
entries.set(path, { path, lastmod: null });
|
||||
}
|
||||
for (const entry of [
|
||||
...getContentEntries(GUIDE_CONTENT_DIR, "/guides"),
|
||||
...getContentEntries(BLOG_CONTENT_DIR, "/blogs"),
|
||||
...getContentEntries(DOCS_CONTENT_DIR, "/docs"),
|
||||
]) {
|
||||
entries.set(entry.path, entry);
|
||||
|
||||
@ -8,9 +8,9 @@ import { z } from "zod";
|
||||
|
||||
const pageSchema = frontmatterSchema as any;
|
||||
|
||||
export const guide = defineCollections({
|
||||
export const blog = defineCollections({
|
||||
type: "doc",
|
||||
dir: "content/guides",
|
||||
dir: "content/blogs",
|
||||
schema: pageSchema.extend({
|
||||
author: z.string(),
|
||||
date: z.string(),
|
||||
|
||||
@ -42,7 +42,7 @@ export function SiteFooter({ className }: { className?: string }) {
|
||||
<p className="font-semibold text-neutral-900">Resources</p>
|
||||
<div className="mt-2 flex flex-col gap-1.5">
|
||||
<Link to="/open-source-seo">Why Open Source?</Link>
|
||||
<Link to="/guides">Guides</Link>
|
||||
<Link to="/blogs">Blog</Link>
|
||||
<a href="/docs">Docs</a>
|
||||
<a href="/docs/skills">Skills</a>
|
||||
</div>
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { notFound } from "@tanstack/react-router";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { docsSource, guideSource } from "@/lib/source";
|
||||
import { blogSource, docsSource } from "@/lib/source";
|
||||
|
||||
export const getGuidePost = createServerFn({ method: "GET" })
|
||||
export const getBlogPost = createServerFn({ method: "GET" })
|
||||
.inputValidator((slugs: string[]) => slugs)
|
||||
.handler(async ({ data: slugs }) => {
|
||||
const page = guideSource.getPage(slugs);
|
||||
const page = blogSource.getPage(slugs);
|
||||
if (!page) throw notFound();
|
||||
|
||||
return {
|
||||
@ -16,9 +16,9 @@ export const getGuidePost = createServerFn({ method: "GET" })
|
||||
};
|
||||
});
|
||||
|
||||
export const getGuidePosts = createServerFn({ method: "GET" }).handler(
|
||||
export const getBlogPosts = createServerFn({ method: "GET" }).handler(
|
||||
async () => {
|
||||
const pages = guideSource.getPages();
|
||||
const pages = blogSource.getPages();
|
||||
return pages.map((page: (typeof pages)[number]) => ({
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
|
||||
@ -11,12 +11,12 @@ export function baseOptions(): BaseLayoutProps {
|
||||
links: [
|
||||
{
|
||||
text: "Resources",
|
||||
url: "/guides",
|
||||
url: "/blogs",
|
||||
items: [
|
||||
{
|
||||
text: "Guides",
|
||||
text: "Blog",
|
||||
description: "Founder-focused SEO articles.",
|
||||
url: "/guides",
|
||||
url: "/blogs",
|
||||
},
|
||||
{
|
||||
text: "MCP",
|
||||
|
||||
@ -2,14 +2,14 @@ import { loader } from "fumadocs-core/source";
|
||||
// Keep this server runtime import explicit. In this app's SSR build, the
|
||||
// public Vite runtime entry resolves to the browser variant for sourceAsync.
|
||||
import { fromConfig } from "../../node_modules/fumadocs-mdx/dist/runtime/vite/server.js";
|
||||
import { docs, docsMeta, guide } from "../../source.generated";
|
||||
import { blog, docs, docsMeta } from "../../source.generated";
|
||||
import type * as Config from "../../source.config";
|
||||
|
||||
const serverCreate = fromConfig<typeof Config>();
|
||||
|
||||
export const guideSource = loader({
|
||||
source: await serverCreate.sourceAsync(guide, {} as Record<string, never>),
|
||||
baseUrl: "/guides",
|
||||
export const blogSource = loader({
|
||||
source: await serverCreate.sourceAsync(blog, {} as Record<string, never>),
|
||||
baseUrl: "/blogs",
|
||||
});
|
||||
|
||||
export const docsSource = loader({
|
||||
|
||||
@ -14,10 +14,12 @@ import { Route as PrivacyRouteImport } from './routes/privacy'
|
||||
import { Route as MarketingRouteImport } from './routes/_marketing'
|
||||
import { Route as GuidesIndexRouteImport } from './routes/guides/index'
|
||||
import { Route as DocsIndexRouteImport } from './routes/docs/index'
|
||||
import { Route as BlogsIndexRouteImport } from './routes/blogs/index'
|
||||
import { Route as MarketingIndexRouteImport } from './routes/_marketing/index'
|
||||
import { Route as JsScriptDotjsRouteImport } from './routes/js/script[.]js'
|
||||
import { Route as GuidesSplatRouteImport } from './routes/guides/$'
|
||||
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 MarketingPricingRouteImport } from './routes/_marketing/pricing'
|
||||
@ -58,6 +60,11 @@ const DocsIndexRoute = DocsIndexRouteImport.update({
|
||||
path: '/docs/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const BlogsIndexRoute = BlogsIndexRouteImport.update({
|
||||
id: '/blogs/',
|
||||
path: '/blogs/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const MarketingIndexRoute = MarketingIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@ -78,6 +85,11 @@ const DocsSplatRoute = DocsSplatRouteImport.update({
|
||||
path: '/docs/$',
|
||||
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',
|
||||
@ -172,9 +184,11 @@ export interface FileRoutesByFullPath {
|
||||
'/pricing': typeof MarketingPricingRoute
|
||||
'/api/event': typeof ApiEventRoute
|
||||
'/api/subscribe': typeof ApiSubscribeRoute
|
||||
'/blogs/$': typeof BlogsSplatRoute
|
||||
'/docs/$': typeof DocsSplatRoute
|
||||
'/guides/$': typeof GuidesSplatRoute
|
||||
'/js/script.js': typeof JsScriptDotjsRoute
|
||||
'/blogs/': typeof BlogsIndexRoute
|
||||
'/docs/': typeof DocsIndexRoute
|
||||
'/guides/': typeof GuidesIndexRoute
|
||||
'/features/ai-brand-visibility': typeof MarketingFeaturesAiBrandVisibilityRoute
|
||||
@ -196,10 +210,12 @@ export interface FileRoutesByTo {
|
||||
'/pricing': typeof MarketingPricingRoute
|
||||
'/api/event': typeof ApiEventRoute
|
||||
'/api/subscribe': typeof ApiSubscribeRoute
|
||||
'/blogs/$': typeof BlogsSplatRoute
|
||||
'/docs/$': typeof DocsSplatRoute
|
||||
'/guides/$': typeof GuidesSplatRoute
|
||||
'/js/script.js': typeof JsScriptDotjsRoute
|
||||
'/': typeof MarketingIndexRoute
|
||||
'/blogs': typeof BlogsIndexRoute
|
||||
'/docs': typeof DocsIndexRoute
|
||||
'/guides': typeof GuidesIndexRoute
|
||||
'/features/ai-brand-visibility': typeof MarketingFeaturesAiBrandVisibilityRoute
|
||||
@ -223,10 +239,12 @@ export interface FileRoutesById {
|
||||
'/_marketing/pricing': typeof MarketingPricingRoute
|
||||
'/api/event': typeof ApiEventRoute
|
||||
'/api/subscribe': typeof ApiSubscribeRoute
|
||||
'/blogs/$': typeof BlogsSplatRoute
|
||||
'/docs/$': typeof DocsSplatRoute
|
||||
'/guides/$': typeof GuidesSplatRoute
|
||||
'/js/script.js': typeof JsScriptDotjsRoute
|
||||
'/_marketing/': typeof MarketingIndexRoute
|
||||
'/blogs/': typeof BlogsIndexRoute
|
||||
'/docs/': typeof DocsIndexRoute
|
||||
'/guides/': typeof GuidesIndexRoute
|
||||
'/_marketing/features/ai-brand-visibility': typeof MarketingFeaturesAiBrandVisibilityRoute
|
||||
@ -251,9 +269,11 @@ export interface FileRouteTypes {
|
||||
| '/pricing'
|
||||
| '/api/event'
|
||||
| '/api/subscribe'
|
||||
| '/blogs/$'
|
||||
| '/docs/$'
|
||||
| '/guides/$'
|
||||
| '/js/script.js'
|
||||
| '/blogs/'
|
||||
| '/docs/'
|
||||
| '/guides/'
|
||||
| '/features/ai-brand-visibility'
|
||||
@ -275,10 +295,12 @@ export interface FileRouteTypes {
|
||||
| '/pricing'
|
||||
| '/api/event'
|
||||
| '/api/subscribe'
|
||||
| '/blogs/$'
|
||||
| '/docs/$'
|
||||
| '/guides/$'
|
||||
| '/js/script.js'
|
||||
| '/'
|
||||
| '/blogs'
|
||||
| '/docs'
|
||||
| '/guides'
|
||||
| '/features/ai-brand-visibility'
|
||||
@ -301,10 +323,12 @@ export interface FileRouteTypes {
|
||||
| '/_marketing/pricing'
|
||||
| '/api/event'
|
||||
| '/api/subscribe'
|
||||
| '/blogs/$'
|
||||
| '/docs/$'
|
||||
| '/guides/$'
|
||||
| '/js/script.js'
|
||||
| '/_marketing/'
|
||||
| '/blogs/'
|
||||
| '/docs/'
|
||||
| '/guides/'
|
||||
| '/_marketing/features/ai-brand-visibility'
|
||||
@ -325,9 +349,11 @@ export interface RootRouteChildren {
|
||||
TermsAndConditionsRoute: typeof TermsAndConditionsRoute
|
||||
ApiEventRoute: typeof ApiEventRoute
|
||||
ApiSubscribeRoute: typeof ApiSubscribeRoute
|
||||
BlogsSplatRoute: typeof BlogsSplatRoute
|
||||
DocsSplatRoute: typeof DocsSplatRoute
|
||||
GuidesSplatRoute: typeof GuidesSplatRoute
|
||||
JsScriptDotjsRoute: typeof JsScriptDotjsRoute
|
||||
BlogsIndexRoute: typeof BlogsIndexRoute
|
||||
DocsIndexRoute: typeof DocsIndexRoute
|
||||
GuidesIndexRoute: typeof GuidesIndexRoute
|
||||
}
|
||||
@ -369,6 +395,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof DocsIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/blogs/': {
|
||||
id: '/blogs/'
|
||||
path: '/blogs'
|
||||
fullPath: '/blogs/'
|
||||
preLoaderRoute: typeof BlogsIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_marketing/': {
|
||||
id: '/_marketing/'
|
||||
path: '/'
|
||||
@ -397,6 +430,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof DocsSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/blogs/$': {
|
||||
id: '/blogs/$'
|
||||
path: '/blogs/$'
|
||||
fullPath: '/blogs/$'
|
||||
preLoaderRoute: typeof BlogsSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/subscribe': {
|
||||
id: '/api/subscribe'
|
||||
path: '/api/subscribe'
|
||||
@ -550,9 +590,11 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
TermsAndConditionsRoute: TermsAndConditionsRoute,
|
||||
ApiEventRoute: ApiEventRoute,
|
||||
ApiSubscribeRoute: ApiSubscribeRoute,
|
||||
BlogsSplatRoute: BlogsSplatRoute,
|
||||
DocsSplatRoute: DocsSplatRoute,
|
||||
GuidesSplatRoute: GuidesSplatRoute,
|
||||
JsScriptDotjsRoute: JsScriptDotjsRoute,
|
||||
BlogsIndexRoute: BlogsIndexRoute,
|
||||
DocsIndexRoute: DocsIndexRoute,
|
||||
GuidesIndexRoute: GuidesIndexRoute,
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ const mobileNavItems = [
|
||||
{
|
||||
label: "Resources",
|
||||
links: [
|
||||
{ label: "Guides", href: "/guides" },
|
||||
{ label: "Blog", href: "/blogs" },
|
||||
{ label: "Why Open Source?", href: "/open-source-seo" },
|
||||
{ label: "MCP Setup", href: "/docs/mcp" },
|
||||
{ label: "Skills", href: "/docs/skills" },
|
||||
@ -199,8 +199,8 @@ function MarketingLayout() {
|
||||
function ResourcesDropdown() {
|
||||
const resources = [
|
||||
{
|
||||
label: "Guides",
|
||||
href: "/guides",
|
||||
label: "Blog",
|
||||
href: "/blogs",
|
||||
description: "Founder-focused SEO articles.",
|
||||
},
|
||||
{
|
||||
@ -223,7 +223,7 @@ function ResourcesDropdown() {
|
||||
return (
|
||||
<div className="relative group">
|
||||
<a
|
||||
href="/guides"
|
||||
href="/blogs"
|
||||
className="text-sm font-semibold text-neutral-600 transition-colors hover:text-neutral-900 md:hidden"
|
||||
>
|
||||
Resources
|
||||
|
||||
99
web/src/routes/blogs/$.tsx
Normal file
99
web/src/routes/blogs/$.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { createFileRoute, Link } 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 ?? "OpenSEO Blog";
|
||||
const description = data?.description;
|
||||
return buildPageSeo({
|
||||
title,
|
||||
description,
|
||||
path: data?.url ?? "/blogs",
|
||||
titleSuffix: "OpenSEO Blog",
|
||||
ogType: "article",
|
||||
});
|
||||
},
|
||||
component: BlogPost,
|
||||
});
|
||||
|
||||
const clientMdxLoader = createClientLoader(blog, {
|
||||
id: "blog",
|
||||
component({ default: MDX }) {
|
||||
return (
|
||||
<DocsBody className="text-fd-foreground [&_h2]:text-fd-foreground [&_h3]:text-fd-foreground [&_li]:text-fd-foreground/90 [&_p]:text-fd-foreground/90 [&_strong]:text-fd-foreground">
|
||||
<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 text-fd-foreground">
|
||||
<BlogHeader title={data.title} description={data.description} />
|
||||
<Suspense>
|
||||
<Content />
|
||||
</Suspense>
|
||||
</article>
|
||||
</HomeLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function BlogHeader({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<header className="mb-8">
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
to="/blogs"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-fd-muted-foreground transition-colors hover:text-fd-primary"
|
||||
>
|
||||
<span aria-hidden="true">←</span>
|
||||
<span>Back to Blog</span>
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="mb-4 text-4xl font-bold text-fd-foreground md:text-5xl">
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="max-w-2xl text-lg leading-8 text-fd-muted-foreground md:text-xl">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
57
web/src/routes/blogs/index.tsx
Normal file
57
web/src/routes/blogs/index.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
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 = "Founder-focused SEO articles from OpenSEO.";
|
||||
|
||||
export const Route = createFileRoute("/blogs/")({
|
||||
head: () =>
|
||||
buildPageSeo({
|
||||
title: "OpenSEO 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 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>
|
||||
);
|
||||
}
|
||||
@ -1,99 +1,11 @@
|
||||
import { createFileRoute, Link } 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 { getGuidePost } from "@/lib/content.functions";
|
||||
import { guide } from "../../../source.generated";
|
||||
import { buildPageSeo } from "@/lib/seo";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/guides/$")({
|
||||
loader: async ({ params }: { params: { _splat?: string } }) => {
|
||||
const slugs = params._splat?.split("/") ?? [];
|
||||
const data = await getGuidePost({ 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 ?? "OpenSEO Guides";
|
||||
const description = data?.description;
|
||||
return buildPageSeo({
|
||||
title,
|
||||
description,
|
||||
path: data?.url ?? "/guides",
|
||||
titleSuffix: "OpenSEO Guides",
|
||||
ogType: "article",
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: "/blogs/$",
|
||||
params: { _splat: params._splat },
|
||||
statusCode: 301,
|
||||
});
|
||||
},
|
||||
component: GuidePost,
|
||||
});
|
||||
|
||||
const clientMdxLoader = createClientLoader(guide, {
|
||||
id: "guide",
|
||||
component({ default: MDX }) {
|
||||
return (
|
||||
<DocsBody className="text-fd-foreground [&_h2]:text-fd-foreground [&_h3]:text-fd-foreground [&_li]:text-fd-foreground/90 [&_p]:text-fd-foreground/90 [&_strong]:text-fd-foreground">
|
||||
<MDX
|
||||
components={{
|
||||
...defaultMdxComponents,
|
||||
}}
|
||||
/>
|
||||
</DocsBody>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function GuidePost() {
|
||||
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 text-fd-foreground">
|
||||
<GuideHeader title={data.title} description={data.description} />
|
||||
<Suspense>
|
||||
<Content />
|
||||
</Suspense>
|
||||
</article>
|
||||
</HomeLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function GuideHeader({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<header className="mb-8">
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
to="/guides"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-fd-muted-foreground transition-colors hover:text-fd-primary"
|
||||
>
|
||||
<span aria-hidden="true">←</span>
|
||||
<span>Back to Guides</span>
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="mb-4 text-4xl font-bold text-fd-foreground md:text-5xl">
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="max-w-2xl text-lg leading-8 text-fd-muted-foreground md:text-xl">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,60 +1,10 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { HomeLayout } from "fumadocs-ui/layouts/home";
|
||||
import { baseOptions } from "@/lib/layout.shared";
|
||||
import { getGuidePosts } from "@/lib/content.functions";
|
||||
import { buildPageSeo } from "@/lib/seo";
|
||||
|
||||
const guideIndexDescription = "Founder-focused guides from OpenSEO.";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/guides/")({
|
||||
head: () =>
|
||||
buildPageSeo({
|
||||
title: "OpenSEO Guides",
|
||||
description: guideIndexDescription,
|
||||
path: "/guides",
|
||||
}),
|
||||
component: GuideIndex,
|
||||
loader: async () => await getGuidePosts(),
|
||||
beforeLoad: () => {
|
||||
throw redirect({
|
||||
to: "/blogs",
|
||||
statusCode: 301,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function GuideIndex() {
|
||||
const guides = 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">Guides</h1>
|
||||
|
||||
{guides.length === 0 ? (
|
||||
<p className="text-fd-muted-foreground">
|
||||
No guides yet. Check back soon.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{guides.map((guide) => (
|
||||
<article
|
||||
key={guide.url}
|
||||
className="border-b pb-8 last:border-b-0"
|
||||
>
|
||||
<Link
|
||||
to="/guides/$"
|
||||
params={{ _splat: guide.slugs.join("/") }}
|
||||
className="group"
|
||||
>
|
||||
<h2 className="text-2xl font-semibold group-hover:text-fd-primary transition-colors mb-2">
|
||||
{guide.title}
|
||||
</h2>
|
||||
{guide.description && (
|
||||
<p className="text-fd-muted-foreground">
|
||||
{guide.description}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HomeLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user