diff --git a/apps/docs/package.json b/apps/docs/package.json
index bbc5809..b823acb 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -12,6 +12,7 @@
"lint": "eslint .",
"format": "prettier --write .",
"gen:props": "node scripts/gen-props.mjs",
+ "gen:og": "node scripts/gen-og.mjs",
"gen:props:check": "node scripts/gen-props.mjs && git diff --exit-code -- src/lib/docs/generated/props.json",
"check:render": "node scripts/check-render.mjs",
"check:render:update": "node scripts/check-render.mjs --update",
diff --git a/apps/docs/scripts/gen-og.mjs b/apps/docs/scripts/gen-og.mjs
new file mode 100644
index 0000000..26118a6
--- /dev/null
+++ b/apps/docs/scripts/gen-og.mjs
@@ -0,0 +1,120 @@
+/**
+ * Generates static/og.png — the 1200×630 social card referenced by og:image.
+ *
+ * Rendered with the Playwright Chromium already installed for the visual suite,
+ * so there is no extra dependency. It must be a raster image: X, Slack, LinkedIn
+ * and Facebook all refuse to render an SVG og:image, which is why the brand SVG
+ * cannot simply be pointed at.
+ *
+ * Run with `pnpm gen:og` after changing the tagline, the count or the branding.
+ */
+import { readFileSync, writeFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+import { chromium } from '@playwright/test';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const root = resolve(here, '..');
+
+const version = JSON.parse(
+ readFileSync(resolve(root, '../../packages/sve-ui/package.json'), 'utf8')
+).version;
+
+// Counted from the registry rather than hardcoded so the card cannot claim a
+// number the site itself contradicts. Comments are stripped first: the file's
+// own header documents `ready: true` in prose and would inflate the count by one.
+const registry = readFileSync(resolve(root, 'src/lib/docs/registry.ts'), 'utf8')
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/\/\/.*$/gm, '');
+const readyCount = (registry.match(/ready:\s*true/g) ?? []).length;
+
+const BG = '#0d0d11';
+const FG = '#f4f4f6';
+const MUTED = '#9a9aa6';
+const PRIMARY = '#f56565';
+const PRIMARY_TEXT = '#ff8a8a';
+const BORDER = '#26262f';
+
+const html = `
+
+
+
+
+
+
+
+
+
+
S
+
Sve·UI
+
v${version}
+
+
The Svelte 5 UI component library — zero config.
+
Fully styled, fully accessible components built on Bits UI.
diff --git a/apps/docs/src/lib/seo/Seo.svelte b/apps/docs/src/lib/seo/Seo.svelte
new file mode 100644
index 0000000..326c029
--- /dev/null
+++ b/apps/docs/src/lib/seo/Seo.svelte
@@ -0,0 +1,101 @@
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if structuredData}
+
+
+ {@html structuredData}
+ {/if}
+
diff --git a/apps/docs/src/lib/seo/schema.ts b/apps/docs/src/lib/seo/schema.ts
new file mode 100644
index 0000000..856ce82
--- /dev/null
+++ b/apps/docs/src/lib/seo/schema.ts
@@ -0,0 +1,114 @@
+/**
+ * Schema.org structured data builders.
+ *
+ * These describe the library to search engines in a machine-readable way, which
+ * is what makes a result eligible for rich treatment (breadcrumb trails, sitelinks
+ * search, software app cards) instead of a plain blue link.
+ */
+
+import pkg from 'sve-ui/package.json';
+import { SITE_URL, SITE_NAME, SITE_DESCRIPTION, REPO_URL, absolute, OG_IMAGE } from './site';
+
+const CONTEXT = 'https://schema.org';
+
+/** Identifies the site itself and enables the sitelinks search box. */
+export function websiteSchema(): Record {
+ return {
+ '@context': CONTEXT,
+ '@type': 'WebSite',
+ '@id': `${SITE_URL}/#website`,
+ name: SITE_NAME,
+ alternateName: 'sve-ui',
+ url: SITE_URL,
+ description: SITE_DESCRIPTION,
+ inLanguage: 'en'
+ };
+}
+
+/**
+ * The library as a product. `offers` at price 0 is not decoration: Google's
+ * software-app rich result requires either an offer or an aggregate rating, and
+ * a free MIT package genuinely has the former.
+ */
+export function softwareSchema(): Record {
+ return {
+ '@context': CONTEXT,
+ '@type': 'SoftwareApplication',
+ '@id': `${SITE_URL}/#software`,
+ name: 'sve-ui',
+ alternateName: SITE_NAME,
+ applicationCategory: 'DeveloperApplication',
+ applicationSubCategory: 'UI Component Library',
+ description: SITE_DESCRIPTION,
+ url: SITE_URL,
+ image: absolute(OG_IMAGE),
+ softwareVersion: pkg.version,
+ license: 'https://opensource.org/licenses/MIT',
+ codeRepository: REPO_URL,
+ programmingLanguage: ['Svelte', 'TypeScript'],
+ operatingSystem: 'Any',
+ keywords: pkg.keywords?.join(', '),
+ author: {
+ '@type': 'Person',
+ name: 'Rodrigo Abregu',
+ url: 'https://rodriab.io/'
+ },
+ offers: {
+ '@type': 'Offer',
+ price: '0',
+ priceCurrency: 'USD'
+ }
+ };
+}
+
+/** A page documenting one component, tied back to the library it belongs to. */
+export function techArticleSchema(input: {
+ name: string;
+ description: string;
+ path: string;
+}): Record {
+ return {
+ '@context': CONTEXT,
+ '@type': 'TechArticle',
+ headline: input.name,
+ description: input.description,
+ url: absolute(input.path),
+ image: absolute(OG_IMAGE),
+ inLanguage: 'en',
+ isPartOf: { '@id': `${SITE_URL}/#website` },
+ about: { '@id': `${SITE_URL}/#software` },
+ author: { '@type': 'Person', name: 'Rodrigo Abregu', url: 'https://rodriab.io/' }
+ };
+}
+
+/** Renders the crumb trail Google shows in place of a raw URL. */
+export function breadcrumbSchema(
+ crumbs: { name: string; path: string }[]
+): Record {
+ return {
+ '@context': CONTEXT,
+ '@type': 'BreadcrumbList',
+ itemListElement: crumbs.map((c, i) => ({
+ '@type': 'ListItem',
+ position: i + 1,
+ name: c.name,
+ item: absolute(c.path)
+ }))
+ };
+}
+
+/** The component index as an enumerated list — one entry per documented component. */
+export function itemListSchema(items: { name: string; path: string }[]): Record {
+ return {
+ '@context': CONTEXT,
+ '@type': 'ItemList',
+ name: 'Svelte UI Components',
+ numberOfItems: items.length,
+ itemListElement: items.map((it, i) => ({
+ '@type': 'ListItem',
+ position: i + 1,
+ name: it.name,
+ url: absolute(it.path)
+ }))
+ };
+}
diff --git a/apps/docs/src/lib/seo/site.ts b/apps/docs/src/lib/seo/site.ts
new file mode 100644
index 0000000..0a412b0
--- /dev/null
+++ b/apps/docs/src/lib/seo/site.ts
@@ -0,0 +1,36 @@
+/**
+ * Canonical site identity — the single source of truth for every absolute URL
+ * the site emits: canonical tags, Open Graph, structured data and the sitemap.
+ *
+ * Absolute URLs are unavoidable in SEO markup (a canonical or an og:image MUST
+ * be absolute), so they are derived here instead of being spelled out per page.
+ * Change the domain once and nothing is left pointing at the old one.
+ */
+
+export const SITE_URL = 'https://sveui.org';
+
+export const SITE_NAME = 'Sve·UI';
+
+/** Primary keyword phrase. Reused across titles, descriptions and JSON-LD. */
+export const SITE_TAGLINE = 'Svelte UI Component Library';
+
+export const SITE_DESCRIPTION =
+ 'Sve·UI is a Svelte 5 UI component library: fully styled, fully accessible components built on Bits UI. No Tailwind and no config in your project — install, import, and theme with CSS variables.';
+
+/** 1200×630 social card. Regenerate with `pnpm gen:og`. */
+export const OG_IMAGE = '/og.png';
+export const OG_IMAGE_WIDTH = 1200;
+export const OG_IMAGE_HEIGHT = 630;
+export const OG_IMAGE_ALT = 'Sve·UI — styled, accessible Svelte 5 UI components. Zero config.';
+
+export const REPO_URL = 'https://github.com/rodriabregu/sve-ui';
+export const NPM_URL = 'https://www.npmjs.com/package/sve-ui';
+
+/**
+ * Resolve a site-relative path to its absolute canonical URL.
+ * `/` stays `https://sveui.org/`; every other path keeps SvelteKit's default
+ * `trailingSlash: 'never'` shape so canonicals match the URLs actually served.
+ */
+export function absolute(path: string): string {
+ return new URL(path, SITE_URL).href;
+}
diff --git a/apps/docs/src/routes/+page.svelte b/apps/docs/src/routes/+page.svelte
index a297d86..104e9fa 100644
--- a/apps/docs/src/routes/+page.svelte
+++ b/apps/docs/src/routes/+page.svelte
@@ -1,5 +1,7 @@
-
- Sve·UI — Styled, accessible Svelte 5 components. Zero config.
-
-
+
- Styled, accessible Svelte 5 components — Svelte 5 UI components — zero config.
@@ -131,7 +131,8 @@
class="mt-6 max-w-[480px]"
style="font-size: 17.5px; line-height: 1.55; color: var(--doc-fg-muted);"
>
- Fully styled, fully accessible components built on Bits UI.
+ The Svelte UI component library with fully styled, fully accessible components built on
+ Bits UI.
No Tailwind. No config
in your project —
import { componentGroups, readyComponents, totalComponents } from '$lib/docs/registry';
+ import Seo from '$lib/seo/Seo.svelte';
+ import { breadcrumbSchema, itemListSchema } from '$lib/seo/schema';
const comingSoon = totalComponents - readyComponents;
+
+ // The index is the hub every component page links back to, so it gets the
+ // broad category term while the leaf pages take the specific ones.
+ const listed = componentGroups.flatMap((g) =>
+ g.items
+ .filter((it) => it.ready)
+ .map((it) => ({ name: it.name, path: `/components/${it.slug}` }))
+ );
-
- Components — Sve·UI
-
-
+
{readyComponents} components · {comingSoon} on the way
-
Components
+
Svelte UI Components
Every component is fully styled and accessible out of the box — built on Bits UI, themed with
CSS variables, and ready to drop in. Pick one to see live previews, code and props. Items
diff --git a/apps/docs/src/routes/docs/+page.svelte b/apps/docs/src/routes/docs/+page.svelte
index 9190789..bf4bdc7 100644
--- a/apps/docs/src/routes/docs/+page.svelte
+++ b/apps/docs/src/routes/docs/+page.svelte
@@ -17,6 +17,8 @@
description="A library of ready-made, fully styled and accessible Svelte 5 components — no Tailwind and no config in your project."
{toc}
{crumb}
+ kind="guide"
+ seoTitle="Introduction to Sve·UI — Svelte 5 UI Component Library"
>
What is sve-ui
diff --git a/apps/docs/src/routes/docs/ai-agents/+page.svelte b/apps/docs/src/routes/docs/ai-agents/+page.svelte
index 6f2a684..f878e8c 100644
--- a/apps/docs/src/routes/docs/ai-agents/+page.svelte
+++ b/apps/docs/src/routes/docs/ai-agents/+page.svelte
@@ -32,6 +32,8 @@ cp -r node_modules/sve-ui/skills/sve-ui-usage .claude/skills/
description="A packaged skill so AI agents (Claude Code, Cursor, …) generate correct sve-ui code instead of hallucinating props."
{toc}
{crumb}
+ kind="guide"
+ seoTitle="Sve·UI for AI Agents — Svelte Skill for Claude Code & Cursor"
>
Why a skill
diff --git a/apps/docs/src/routes/docs/installation/+page.svelte b/apps/docs/src/routes/docs/installation/+page.svelte
index 9ce1050..af3d57e 100644
--- a/apps/docs/src/routes/docs/installation/+page.svelte
+++ b/apps/docs/src/routes/docs/installation/+page.svelte
@@ -44,6 +44,8 @@ import 'sve-ui/theme.css';`;
description="Add the package, import the stylesheet once, and start using components. No Tailwind, no config."
{toc}
{crumb}
+ kind="guide"
+ seoTitle="Install Sve·UI — Svelte UI Component Library Setup"
>
Install
diff --git a/apps/docs/src/routes/docs/theming/+page.svelte b/apps/docs/src/routes/docs/theming/+page.svelte
index 055d54e..67a4b5d 100644
--- a/apps/docs/src/routes/docs/theming/+page.svelte
+++ b/apps/docs/src/routes/docs/theming/+page.svelte
@@ -44,6 +44,8 @@ $effect(() => {
description="Every color, radius and space is a --sve-* CSS variable. Override them anywhere — no rebuild, no config."
{toc}
{crumb}
+ kind="guide"
+ seoTitle="Theming Svelte Components with CSS Variables — Sve·UI"
>
The token model
diff --git a/apps/docs/src/routes/playground/+page.svelte b/apps/docs/src/routes/playground/+page.svelte
index c84d28f..00951b0 100644
--- a/apps/docs/src/routes/playground/+page.svelte
+++ b/apps/docs/src/routes/playground/+page.svelte
@@ -1,4 +1,5 @@
-Playground — Sve·UI
+
diff --git a/apps/docs/src/routes/sitemap.xml/+server.ts b/apps/docs/src/routes/sitemap.xml/+server.ts
new file mode 100644
index 0000000..9d3f492
--- /dev/null
+++ b/apps/docs/src/routes/sitemap.xml/+server.ts
@@ -0,0 +1,44 @@
+import { componentGroups } from '$lib/docs/registry';
+import { guideGroups } from '$lib/docs/guides';
+import { SITE_URL } from '$lib/seo/site';
+
+/**
+ * /sitemap.xml — generated from the component registry and guide nav, exactly
+ * like /llms.txt, so a new component page is discoverable by search engines the
+ * moment it is registered. A hand-maintained list would drift on the first PR.
+ */
+export const prerender = true;
+
+/** Only pages that actually render. `ready: false` components are 404s. */
+function urls(): string[] {
+ const components = componentGroups.flatMap((g) =>
+ g.items.filter((it) => it.ready).map((it) => `/components/${it.slug}`)
+ );
+ const guides = guideGroups.flatMap((g) => g.items.map((it) => it.href));
+
+ return ['/', '/components', ...guides, ...components, '/playground'];
+}
+
+export function GET() {
+ // Prerendered, so this is the build timestamp — which for this site is the
+ // deploy that followed the last content or release change.
+ const lastmod = new Date().toISOString();
+
+ const body = `
+
+${urls()
+ .map(
+ (path) =>
+ `\t\n\t\t${SITE_URL}${path === '/' ? '/' : path}\n\t\t${lastmod}\n\t`
+ )
+ .join('\n')}
+
+`;
+
+ return new Response(body, {
+ headers: {
+ 'Content-Type': 'application/xml; charset=utf-8',
+ 'Cache-Control': 'public, max-age=0, s-maxage=3600'
+ }
+ });
+}
diff --git a/apps/docs/static/og.png b/apps/docs/static/og.png
new file mode 100644
index 0000000..373558d
Binary files /dev/null and b/apps/docs/static/og.png differ
diff --git a/apps/docs/static/robots.txt b/apps/docs/static/robots.txt
index e9e57dc..06084f3 100644
--- a/apps/docs/static/robots.txt
+++ b/apps/docs/static/robots.txt
@@ -1,3 +1,5 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
-Disallow:
+Allow: /
+
+Sitemap: https://sveui.org/sitemap.xml