From 7a03ed9e1cc93a2d0e9cb9678d2660059dab4f3c Mon Sep 17 00:00:00 2001 From: Joao Henrique Machado Silva Date: Sat, 9 May 2026 14:37:51 +0200 Subject: [PATCH] feat(web): technical SEO foundation for sqlritedb.com (SQLR-32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Per-route titles / descriptions / canonicals on / and /docs - OpenGraph + Twitter cards (siteName, locale, summary_large_image) restated at the page level since Next 15 shallow-merges these - Auto-generated OG + Twitter images via next/og ImageResponse, shared `OgFrame` JSX in src/lib/og.tsx; brand mark inlined as SVG so Satori's dynamic-font fetcher doesn't 400 on the ▸ glyph - app/sitemap.ts + app/robots.ts metadata routes; sitemap pointer in robots and SITE.url canonical in both - JSON-LD: SoftwareApplication on landing, BreadcrumbList on /docs - Site-wide robots policy, keywords, author, and a placeholder verification block in the root layout for GSC + Bing tokens - Heading hierarchy: footer column h4→h3, /docs TOC h4→h2 (with aria-label) so headings descend in order - web/README.md SEO surface section documenting each piece Co-Authored-By: Claude Opus 4.7 (1M context) --- web/README.md | 35 +++++++ web/src/app/docs/opengraph-image.tsx | 20 ++++ web/src/app/docs/page.tsx | 54 +++++++++- web/src/app/docs/twitter-image.tsx | 20 ++++ web/src/app/globals.css | 4 +- web/src/app/layout.tsx | 67 +++++++++++-- web/src/app/opengraph-image.tsx | 20 ++++ web/src/app/page.tsx | 68 +++++++++++++ web/src/app/robots.ts | 15 +++ web/src/app/sitemap.ts | 25 +++++ web/src/app/twitter-image.tsx | 20 ++++ web/src/components/footer.tsx | 6 +- web/src/lib/og.tsx | 144 +++++++++++++++++++++++++++ web/src/lib/site.ts | 2 + 14 files changed, 480 insertions(+), 20 deletions(-) create mode 100644 web/src/app/docs/opengraph-image.tsx create mode 100644 web/src/app/docs/twitter-image.tsx create mode 100644 web/src/app/opengraph-image.tsx create mode 100644 web/src/app/robots.ts create mode 100644 web/src/app/sitemap.ts create mode 100644 web/src/app/twitter-image.tsx create mode 100644 web/src/lib/og.tsx diff --git a/web/README.md b/web/README.md index 6bd3679..aad2a37 100644 --- a/web/README.md +++ b/web/README.md @@ -18,6 +18,41 @@ extracted into its own repository later without rewrites. - `/` — landing (hero with animated REPL, features, architecture, roadmap, SDK switcher, SQL surface, desktop showcase, blog series, footer) - `/docs` — Getting Started page (sticky sidebar nav + on-page TOC) +## SEO surface + +Each public route ships full search/social metadata. The pieces: + +- **Per-route `` + `<meta name="description">`** — declared via the + Next App-Router `metadata` export on each `page.tsx` (and a site-wide + template in [`src/app/layout.tsx`](src/app/layout.tsx)). +- **Canonical URL** — `alternates.canonical` on every page; prevents the + `/docs` tree (and any future hash/query variants) from being treated as + duplicates. +- **OpenGraph + Twitter Card** — full set of `og:*` and `twitter:*` tags per + route. Heads-up: Next 15 does **not** deep-merge `openGraph` / `twitter` + between layout and page, so site-wide fields (`siteName`, `card`, + `site`/`creator`) are restated on each page-level override. +- **Auto-generated OG images** — every page has a sibling + `opengraph-image.tsx` + `twitter-image.tsx` rendered via + `next/og`'s `ImageResponse` at the edge. Layout lives in + [`src/lib/og.tsx`](src/lib/og.tsx) so each route just supplies a + page-specific eyebrow / title / subtitle. The brand mark is inlined as + SVG (Satori's dynamic-font fallback 400s on uncommon glyphs). +- **`/sitemap.xml` + `/robots.txt`** — Next 15 metadata routes + ([`src/app/sitemap.ts`](src/app/sitemap.ts), + [`src/app/robots.ts`](src/app/robots.ts)). Add a route to the `ROUTES` + list when shipping a new page. +- **JSON-LD structured data** — `SoftwareApplication` schema on the landing + page, `BreadcrumbList` on `/docs`. Validate via Google's + [Rich Results Test](https://search.google.com/test/rich-results). +- **Search Console verification** — fill in the placeholder tokens in + `metadata.verification` ([`src/app/layout.tsx`](src/app/layout.tsx)) once + Google Search Console + Bing Webmaster Tools issue them. + +The canonical site URL + Twitter handle live in +[`src/lib/site.ts`](src/lib/site.ts) (`SITE.url`, `SITE.twitterHandle`) — +update both there if the domain or handle ever changes. + ## Local development ```sh diff --git a/web/src/app/docs/opengraph-image.tsx b/web/src/app/docs/opengraph-image.tsx new file mode 100644 index 0000000..8ca13a1 --- /dev/null +++ b/web/src/app/docs/opengraph-image.tsx @@ -0,0 +1,20 @@ +import { ImageResponse } from "next/og"; +import { OG_CONTENT_TYPE, OG_SIZE, OgFrame } from "@/lib/og"; + +export const runtime = "edge"; +export const alt = "SQLRite docs — getting started"; +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; + +export default function OgImage() { + return new ImageResponse( + ( + <OgFrame + eyebrow="docs · getting started" + title="From cargo install to a persistent on-disk database in ten minutes." + subtitle="REPL, transactions, JOINs, prepared statements, vector + BM25 search, MCP, and six language SDKs." + /> + ), + { ...size }, + ); +} diff --git a/web/src/app/docs/page.tsx b/web/src/app/docs/page.tsx index ae1be47..f0169d0 100644 --- a/web/src/app/docs/page.tsx +++ b/web/src/app/docs/page.tsx @@ -4,15 +4,59 @@ import { Footer } from "@/components/footer"; import { Nav } from "@/components/nav"; import { SITE } from "@/lib/site"; +const TITLE = "Getting started with SQLRite"; +const DESCRIPTION = + "A ten-minute tour from cargo install to a persistent on-disk SQLRite database — REPL, transactions, JOINs, vector search, BM25 full-text, and six language SDKs."; + export const metadata: Metadata = { - title: "Getting started · SQLRite docs", - description: - "A ten-minute tour from cargo install to a persistent on-disk SQLRite database with real transactions, vector search, and full-text search.", + title: TITLE, + description: DESCRIPTION, + alternates: { canonical: "/docs" }, + openGraph: { + type: "article", + siteName: "SQLRite", + locale: "en_US", + url: `${SITE.url}/docs`, + title: TITLE, + description: DESCRIPTION, + }, + twitter: { + card: "summary_large_image", + site: SITE.twitterHandle, + creator: SITE.twitterHandle, + title: TITLE, + description: DESCRIPTION, + }, +}; + +const breadcrumbJsonLd = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: "Home", + item: SITE.url, + }, + { + "@type": "ListItem", + position: 2, + name: "Docs", + item: `${SITE.url}/docs`, + }, + ], }; export default function DocsPage() { return ( <> + <script + type="application/ld+json" + dangerouslySetInnerHTML={{ + __html: JSON.stringify(breadcrumbJsonLd), + }} + /> <Nav variant="docs" /> <div className="docs-shell"> <aside className="docs-side"> @@ -733,8 +777,8 @@ export default function DocsPage() { </div> </main> - <aside className="toc"> - <h4>On this page</h4> + <aside className="toc" aria-label="On this page"> + <h2 className="toc-title">On this page</h2> <a href="#install">Install</a> <a href="#first-db">Your first database</a> <a href="#repl">Using the REPL</a> diff --git a/web/src/app/docs/twitter-image.tsx b/web/src/app/docs/twitter-image.tsx new file mode 100644 index 0000000..8461fcf --- /dev/null +++ b/web/src/app/docs/twitter-image.tsx @@ -0,0 +1,20 @@ +import { ImageResponse } from "next/og"; +import { OG_CONTENT_TYPE, OG_SIZE, OgFrame } from "@/lib/og"; + +export const runtime = "edge"; +export const alt = "SQLRite docs — getting started"; +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; + +export default function TwitterImage() { + return new ImageResponse( + ( + <OgFrame + eyebrow="docs · getting started" + title="From cargo install to a persistent on-disk database in ten minutes." + subtitle="REPL, transactions, JOINs, prepared statements, vector + BM25 search, MCP, and six language SDKs." + /> + ), + { ...size }, + ); +} diff --git a/web/src/app/globals.css b/web/src/app/globals.css index e4b5afe..c424037 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1234,7 +1234,7 @@ footer { grid-template-columns: 1fr 1fr; } } -.foot-col h4 { +.foot-col h3 { font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; @@ -1332,7 +1332,7 @@ footer { overflow-y: auto; } .docs-side h4, -.toc h4 { +.toc .toc-title { font-family: var(--font-mono); font-size: 11px; color: var(--color-fg-dim); diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index bc02051..3b9c5f5 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Inter, JetBrains_Mono } from "next/font/google"; +import { SITE } from "@/lib/site"; import "./globals.css"; const inter = Inter({ @@ -16,22 +17,68 @@ const jetbrainsMono = JetBrains_Mono({ display: "swap", }); +const DEFAULT_TITLE = "SQLRite — embedded SQL database, built in Rust"; +const DEFAULT_DESCRIPTION = + "An embedded SQL database modeled after SQLite, built from scratch in Rust. Single-file format, real B-tree, WAL, transactions, vector search, full-text search, and six language SDKs."; + export const metadata: Metadata = { - title: "SQLRite — embedded SQL database, built in Rust", - description: - "An embedded SQL database modeled after SQLite, built from scratch in Rust. Single-file format, real B-tree, WAL, transactions, vector search, full-text search, and six language SDKs.", - metadataBase: new URL("https://sqlrite.dev"), + metadataBase: new URL(SITE.url), + title: { + default: DEFAULT_TITLE, + template: "%s · SQLRite", + }, + description: DEFAULT_DESCRIPTION, + applicationName: "SQLRite", + authors: [{ name: "Joao Henrique Machado Silva", url: SITE.socials.github }], + keywords: [ + "SQLRite", + "Rust SQLite", + "embedded database", + "embedded SQL", + "Rust database", + "vector search", + "HNSW", + "BM25", + "full-text search", + "WAL", + "B-tree", + "MCP", + ], openGraph: { - title: "SQLRite — embedded SQL database, built in Rust", - description: - "Single-file embedded SQL engine in Rust — B-tree, WAL, transactions, HNSW vector search, BM25 full-text, six language SDKs.", type: "website", + siteName: "SQLRite", + locale: "en_US", + url: SITE.url, + title: DEFAULT_TITLE, + description: DEFAULT_DESCRIPTION, }, twitter: { card: "summary_large_image", - title: "SQLRite — embedded SQL database, built in Rust", - description: - "Single-file embedded SQL engine in Rust — B-tree, WAL, transactions, HNSW vector search, BM25 full-text, six language SDKs.", + site: SITE.twitterHandle, + creator: SITE.twitterHandle, + title: DEFAULT_TITLE, + description: DEFAULT_DESCRIPTION, + }, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + "max-image-preview": "large", + "max-snippet": -1, + "max-video-preview": -1, + }, + }, + alternates: { + canonical: "/", + }, + // Search-engine ownership tokens. Fill these in once Google Search Console + // and Bing Webmaster Tools verification is complete (the values are short + // opaque strings issued by each tool). + verification: { + // google: "<google-site-verification-token>", + // other: { "msvalidate.01": "<bing-verification-token>" }, }, }; diff --git a/web/src/app/opengraph-image.tsx b/web/src/app/opengraph-image.tsx new file mode 100644 index 0000000..e3a1e9b --- /dev/null +++ b/web/src/app/opengraph-image.tsx @@ -0,0 +1,20 @@ +import { ImageResponse } from "next/og"; +import { OG_CONTENT_TYPE, OG_SIZE, OgFrame } from "@/lib/og"; + +export const runtime = "edge"; +export const alt = "SQLRite — embedded SQL database, built in Rust"; +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; + +export default function OgImage() { + return new ImageResponse( + ( + <OgFrame + eyebrow="embedded · open source" + title="An embedded SQL database, built from scratch in Rust." + subtitle="Single-file engine. B-tree, WAL, transactions, JOINs, vector + full-text search, six SDKs." + /> + ), + { ...size }, + ); +} diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 4556036..b8d6c6b 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import { Architecture } from "@/components/architecture"; import { Benchmarks } from "@/components/benchmarks"; import { Blog } from "@/components/blog"; @@ -10,10 +11,77 @@ import { Nav } from "@/components/nav"; import { Roadmap } from "@/components/roadmap"; import { SDKShowcase } from "@/components/sdk-showcase"; import { SQLRef } from "@/components/sql-ref"; +import { SITE } from "@/lib/site"; + +const TITLE = "SQLRite — embedded SQL database, built in Rust"; +const DESCRIPTION = + "Single-file embedded SQL engine in Rust. Real B-tree, WAL, transactions, JOINs, aggregates, HNSW vector search, BM25 full-text, plus six language SDKs."; + +export const metadata: Metadata = { + title: { absolute: TITLE }, + description: DESCRIPTION, + alternates: { canonical: "/" }, + // openGraph and twitter aren't deep-merged with the parent layout in Next + // 15 — providing either at the page level fully replaces the layout's + // version, so site-wide fields (siteName, twitter card, etc.) have to be + // restated here. + openGraph: { + type: "website", + siteName: "SQLRite", + locale: "en_US", + url: SITE.url, + title: TITLE, + description: DESCRIPTION, + }, + twitter: { + card: "summary_large_image", + site: SITE.twitterHandle, + creator: SITE.twitterHandle, + title: TITLE, + description: DESCRIPTION, + }, +}; + +// JSON-LD describing SQLRite as a SoftwareApplication. Helps search engines +// surface a rich card (name + description + repo + license) and gives LLM +// crawlers a structured handle on what this project is. +const softwareApplicationJsonLd = { + "@context": "https://schema.org", + "@type": "SoftwareApplication", + name: "SQLRite", + description: DESCRIPTION, + applicationCategory: "DeveloperApplication", + applicationSubCategory: "DatabaseApplication", + operatingSystem: "macOS, Windows, Linux, Web (WASM)", + softwareVersion: SITE.version, + url: SITE.url, + downloadUrl: SITE.releasesLatest, + codeRepository: SITE.repo, + programmingLanguage: "Rust", + license: "https://opensource.org/licenses/MIT", + offers: { + "@type": "Offer", + price: "0", + priceCurrency: "USD", + }, + author: { + "@type": "Person", + name: "Joao Henrique Machado Silva", + url: SITE.socials.github, + }, +}; export default function Home() { return ( <> + <script + type="application/ld+json" + // Stringified ahead of render — the script's content is static, so + // this is not a user-controlled injection vector. + dangerouslySetInnerHTML={{ + __html: JSON.stringify(softwareApplicationJsonLd), + }} + /> <Nav /> <Hero /> <Features /> diff --git a/web/src/app/robots.ts b/web/src/app/robots.ts new file mode 100644 index 0000000..6544730 --- /dev/null +++ b/web/src/app/robots.ts @@ -0,0 +1,15 @@ +import type { MetadataRoute } from "next"; +import { SITE } from "@/lib/site"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: [ + { + userAgent: "*", + allow: "/", + }, + ], + sitemap: `${SITE.url}/sitemap.xml`, + host: SITE.url, + }; +} diff --git a/web/src/app/sitemap.ts b/web/src/app/sitemap.ts new file mode 100644 index 0000000..0f860f5 --- /dev/null +++ b/web/src/app/sitemap.ts @@ -0,0 +1,25 @@ +import type { MetadataRoute } from "next"; +import { SITE } from "@/lib/site"; + +// Static route list. Add an entry whenever a new app-router page lands. +// Once the docs grow into an MDX-driven `/docs/[slug]` tree we'll switch this +// over to enumerate the MDX frontmatter; for now the surface is small enough +// to maintain by hand. +const ROUTES: Array<{ + path: string; + changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]; + priority: number; +}> = [ + { path: "/", changeFrequency: "weekly", priority: 1.0 }, + { path: "/docs", changeFrequency: "weekly", priority: 0.9 }, +]; + +export default function sitemap(): MetadataRoute.Sitemap { + const lastModified = new Date(); + return ROUTES.map(({ path, changeFrequency, priority }) => ({ + url: `${SITE.url}${path === "/" ? "" : path}`, + lastModified, + changeFrequency, + priority, + })); +} diff --git a/web/src/app/twitter-image.tsx b/web/src/app/twitter-image.tsx new file mode 100644 index 0000000..bd3540b --- /dev/null +++ b/web/src/app/twitter-image.tsx @@ -0,0 +1,20 @@ +import { ImageResponse } from "next/og"; +import { OG_CONTENT_TYPE, OG_SIZE, OgFrame } from "@/lib/og"; + +export const runtime = "edge"; +export const alt = "SQLRite — embedded SQL database, built in Rust"; +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; + +export default function TwitterImage() { + return new ImageResponse( + ( + <OgFrame + eyebrow="embedded · open source" + title="An embedded SQL database, built from scratch in Rust." + subtitle="Single-file engine. B-tree, WAL, transactions, JOINs, vector + full-text search, six SDKs." + /> + ), + { ...size }, + ); +} diff --git a/web/src/components/footer.tsx b/web/src/components/footer.tsx index bc27dce..80d8754 100644 --- a/web/src/components/footer.tsx +++ b/web/src/components/footer.tsx @@ -64,21 +64,21 @@ export function Footer() { </div> </div> <div className="foot-col"> - <h4>Project</h4> + <h3>Project</h3> <a href={SITE.repo}>GitHub</a> <Link href="/docs">Documentation</Link> <a href={SITE.docsRs}>Rust API docs</a> <a href={SITE.releases}>Releases</a> </div> <div className="foot-col"> - <h4>Community</h4> + <h3>Community</h3> <a href={SITE.discord}>Discord</a> <a href={`${SITE.repo}/discussions`}>Discussions</a> <a href={`${SITE.repo}/issues`}>Issues</a> <a href="https://github.com/sponsors/joaoh82">Sponsor</a> </div> <div className="foot-col"> - <h4>Reading</h4> + <h3>Reading</h3> <a href={SITE.medium}>Series on Medium</a> <a href="https://cstack.github.io/db_tutorial/"> DB tutorial (inspiration) diff --git a/web/src/lib/og.tsx b/web/src/lib/og.tsx new file mode 100644 index 0000000..6487e16 --- /dev/null +++ b/web/src/lib/og.tsx @@ -0,0 +1,144 @@ +import { SITE } from "./site"; + +export const OG_SIZE = { width: 1200, height: 630 } as const; +export const OG_CONTENT_TYPE = "image/png"; + +type OgFrameProps = { + eyebrow: string; + title: string; + subtitle?: string; +}; + +// Shared visual for every per-route OG image. ImageResponse renders this JSX +// to a 1200×630 PNG via @vercel/og's flat layout engine, so styles must stay +// inline and any flex container with multiple children needs an explicit +// `display: "flex"` (the renderer doesn't infer it the way browsers do). +export function OgFrame({ eyebrow, title, subtitle }: OgFrameProps) { + return ( + <div + style={{ + width: "100%", + height: "100%", + display: "flex", + flexDirection: "column", + justifyContent: "space-between", + padding: "72px", + background: + "linear-gradient(135deg, #07090c 0%, #0d1117 55%, #131a24 100%)", + color: "#e6edf3", + fontFamily: "Inter, system-ui, sans-serif", + }} + > + <div style={{ display: "flex", alignItems: "center", gap: 16 }}> + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 56, + height: 56, + borderRadius: 12, + background: "#f97316", + }} + > + {/* Inline SVG so the OG renderer doesn't need to fetch a dynamic + font subset for the brand mark (Satori's glyph fetcher 400s on + less-common chars and uses a tofu fallback otherwise). */} + <svg + width={28} + height={28} + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + > + <path d="M7 5l12 7-12 7z" fill="#0d1117" /> + </svg> + </div> + <div + style={{ + display: "flex", + flexDirection: "column", + }} + > + <span + style={{ + fontSize: 28, + fontWeight: 600, + letterSpacing: -0.5, + }} + > + sqlrite + </span> + <span + style={{ + fontSize: 14, + color: "#8b949e", + fontFamily: "JetBrains Mono, monospace", + }} + > + v{SITE.version} · embedded SQL in Rust + </span> + </div> + </div> + + <div + style={{ + display: "flex", + flexDirection: "column", + gap: 20, + }} + > + <span + style={{ + fontSize: 16, + fontWeight: 500, + letterSpacing: 1.5, + textTransform: "uppercase", + color: "#f97316", + fontFamily: "JetBrains Mono, monospace", + }} + > + {eyebrow} + </span> + <h1 + style={{ + fontSize: 68, + fontWeight: 600, + lineHeight: 1.05, + letterSpacing: -1.5, + margin: 0, + maxWidth: 1000, + }} + > + {title} + </h1> + {subtitle ? ( + <p + style={{ + fontSize: 28, + lineHeight: 1.35, + color: "#8b949e", + margin: 0, + maxWidth: 1000, + }} + > + {subtitle} + </p> + ) : null} + </div> + + <div + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + fontSize: 18, + color: "#8b949e", + fontFamily: "JetBrains Mono, monospace", + }} + > + <span>sqlritedb.com</span> + <span>github.com/joaoh82/rust_sqlite</span> + </div> + </div> + ); +} diff --git a/web/src/lib/site.ts b/web/src/lib/site.ts index be8447b..8760988 100644 --- a/web/src/lib/site.ts +++ b/web/src/lib/site.ts @@ -1,4 +1,6 @@ export const SITE = { + url: "https://sqlritedb.com", + twitterHandle: "@CodePolyglot", version: "0.9.1", repo: "https://github.com/joaoh82/rust_sqlite", discord: "https://discord.gg/dHPmw89zAE",