diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index cd76f3b..1bc5e2c 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -22,6 +22,16 @@ Add an entry when a choice is hard to reverse, shapes future work, or a future m
## Log
+### D-2026-09-03-01 — Website path URLs for Google indexing
+
+- **Status:** accepted
+- **Context:** The marketing site used HashRouter (`/#/docs`). Google does not treat hash fragments as indexable URLs. Production already had SPA fallbacks.
+- **Decision:** Switch to BrowserRouter; prerender unique HTML for `/`, `/docs`, `/contribute`, `/community`; ship `/robots.txt` and `/sitemap.xml`; unknown paths return HTTP 404; rewrite legacy `#/…` hashes on load.
+- **Alternatives considered:** Keep hashes (rejected: not crawlable as pages); full SSR (rejected: extra stack for a static brochure).
+- **Consequences:** Coolify/nginx must keep SPA fallback for the four routes. Old social posts with `#/docs` still work via the hash rewrite.
+- **Verification:** `npm run website:build`; GET `/robots.txt`, `/sitemap.xml`, `/docs` 200 with unique titles; GET `/no-such-page` 404.
+- **Owner / date:** John Kevin / lead, 2026-09-03
+
### D-2026-08-13-01 — VS Code port: shared `src/lib` + native v1 UX
- **Status:** accepted
diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md
index 97eee41..e65ca29 100644
--- a/docs/DESIGN_SYSTEM.md
+++ b/docs/DESIGN_SYSTEM.md
@@ -31,3 +31,4 @@ Tap targets ≥ 40px; body text contrast AA.
| Feature list | Two-column hairline rows |
| PackageCard | Chrome / VS Code / CLI |
| DocsSection | Package docs blocks |
+| Not found | Unknown path: H1 + Home / Docs |
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index bc97f00..2b9cc75 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -4,8 +4,7 @@
## Current state
-- **Outcome:** Coolify `Cannot GET /` fixed — website now has a production static server and a Dockerfile.
-- **Cause:** Nixpacks built Vite `dist/` but had no `start` command; README also told Coolify to publish `/dist` (absolute empty path).
-- **Shipped:** `packages/website/server.mjs` (`npm start` on `0.0.0.0:$PORT`); `Dockerfile` + `nginx.conf`; `nixpacks.toml` `[start]`; Vite `base` is `/`.
-- **Verified:** `website:typecheck` + `website:build`; local `node server.mjs` → GET `/` 200 (LexAI HTML), `/healthz` 200, JS asset `text/javascript`.
-- **Next smallest action:** Redeploy the Coolify app with Base Directory `packages/website` (no leading slash), static site **off**, Dockerfile port 80 or Nixpacks port 3000.
+- **Outcome:** Website prepared for Google indexing (path URLs, robots, sitemap, unique titles).
+- **Shipped:** BrowserRouter; hash `#/…` rewrite; `public/robots.txt` + `sitemap.xml`; `src/seo.ts` + `SeoHead`; `inject-seo.mjs`; HTTP 404 + `NotFoundPage`; origin `https://lexai.juankibin.space`.
+- **Verified:** `website:build` OK. Local `node server.mjs`: `/` `/docs` `/robots.txt` `/sitemap.xml` 200 (docs title unique; sitemap `application/xml`); `/no-such-page` 404.
+- **Next smallest action:** Redeploy Coolify, then Search Console: verify property, submit sitemap, request index of `/` and `/docs`.
diff --git a/docs/MEMORY.md b/docs/MEMORY.md
index 5d5bac8..8152222 100644
--- a/docs/MEMORY.md
+++ b/docs/MEMORY.md
@@ -32,7 +32,7 @@
| Shared lib | `src/lib/` |
| VS Code | `packages/vscode/` |
| CLI | `packages/cli/` |
-| Website | `packages/website/` (Vite React; Home, Docs, Contribute, Community). Production: `npm start` → `server.mjs`, or `Dockerfile` (nginx :80). Coolify base `packages/website`, static site off. |
+| Website | `packages/website/` (Vite React; Home, Docs, Contribute, Community). BrowserRouter paths `/docs` etc. Origin `https://lexai.juankibin.space`. Production: `npm start` → `server.mjs`, or `Dockerfile` (nginx :80). Coolify base `packages/website`, static site off. |
| OSS meta | root `README.md`, `LICENSE`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md` |
| Issue/PR templates | `.gitea/ISSUE_TEMPLATE/`, `.gitea/PULL_REQUEST_TEMPLATE.md` |
| CI workflows | `.gitea/workflows/ci-{chrome,vscode,cli,website}.yml` (no deploy/release yet) |
diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md
index be49449..aeed01b 100644
--- a/docs/PROGRESS.md
+++ b/docs/PROGRESS.md
@@ -2,21 +2,21 @@
> For the owner. What works, how to see it, and what's waiting on you — plain language.
-**Updated:** 2026-08-17 · **Overall:** Website Coolify 404 is fixed in the repo; needs a redeploy
+**Updated:** 2026-09-03 · **Overall:** Website is ready for Google Search; needs a Coolify redeploy, then Search Console
## What works now
- Chrome extension: select on any page → AI action → Replace/Copy
- VS Code extension: select in editor → LexAI context menu / Command Palette → selection replaced
-- Website (`packages/website`): Home, Docs, Contribute, Community — production server answers `GET /` (this was the Coolify `Cannot GET /` failure)
+- Website (`packages/website`): Home, Docs, Contribute, Community at real paths (`/docs`, not `/#/docs`). `/robots.txt` and `/sitemap.xml` are in the build.
## See it yourself — website on Coolify
1. Pull/push this change, then in Coolify set **Base Directory** to `packages/website` (no leading slash).
2. Turn **Is it a static site?** **off**.
3. Either **Build Pack: Dockerfile** and port **80**, or **Nixpacks** and port **3000**.
-4. Redeploy, then open the domain — you should see the LexAI home page, not `Cannot GET /`.
-5. Docs live at `https://your-domain/#/docs`.
+4. Redeploy, then open `https://lexai.juankibin.space/` — docs at `/docs`.
+5. In [Google Search Console](https://search.google.com/search-console): add that URL, verify ownership, submit `https://lexai.juankibin.space/sitemap.xml`, request indexing for Home and Docs.
Local check: `npm run website:build` then `npm run website:start` and visit http://127.0.0.1:3000/
@@ -24,10 +24,11 @@ Local check: `npm run website:build` then `npm run website:start` and visit http
| # | Decision | Options (recommended bold) | What it unblocks |
| --- | --- | --- | --- |
-| 1 | Redeploy the Coolify website with the settings above | **Dockerfile + port 80** / Nixpacks + port 3000 | public LexAI site |
-| 2 | Confirm Cursor mid/strong model picker IDs | **Keep written IDs** / send exact picker strings | reliable Task dispatch |
-| 3 | Grammarly reference screenshots? | **Later** / capture into `docs/reference/` | Chrome UX gauntlet |
-| 4 | Publish VS Code extension to Marketplace? | **Stay local for now** / set publisher + publish | public install |
+| 1 | Redeploy the Coolify website with the settings above | **Dockerfile + port 80** / Nixpacks + port 3000 | public LexAI site + crawlable URLs |
+| 2 | Google Search Console: verify + submit sitemap | **Do this after redeploy** / later | Google indexing |
+| 3 | Confirm Cursor mid/strong model picker IDs | **Keep written IDs** / send exact picker strings | reliable Task dispatch |
+| 4 | Grammarly reference screenshots? | **Later** / capture into `docs/reference/` | Chrome UX gauntlet |
+| 5 | Publish VS Code extension to Marketplace? | **Stay local for now** / set publisher + publish | public install |
## Next up — proceeds without you
diff --git a/docs/TASKS.md b/docs/TASKS.md
index 8792d8b..a038a04 100644
--- a/docs/TASKS.md
+++ b/docs/TASKS.md
@@ -8,6 +8,7 @@ _No active task contracts._
## Done (recent)
+- **T-WEB-SEO-01** — Google indexing prep for `packages/website` (path URLs, robots/sitemap, unique titles, 404) (2026-09-03).
- **T-VSCODE-01** — VS Code LexAI v1 (`packages/vscode`): native commands/menus, Secret Storage, shared `@lib` via esbuild; verified with `vscode:typecheck` + `vscode:build` + root typecheck/tests (2026-08-13).
## Task contract format
diff --git a/docs/design/website-indexing.md b/docs/design/website-indexing.md
new file mode 100644
index 0000000..fe37161
--- /dev/null
+++ b/docs/design/website-indexing.md
@@ -0,0 +1,47 @@
+# Website indexing (Google)
+
+## User + job
+
+Someone searching for a BYO-LLM writing/code assist tool finds LexAI and lands on the matching page (home, docs, contribute, or community). Success: the snippet title and URL match the page they open.
+
+## Flow
+
+Search result → HTTPS path (`/`, `/docs`, `/contribute`, `/community`) → page. Old `#/docs` links redirect to `/docs` with no extra click. Unknown paths: one screen, link home. Target: 0 taps after the SERP click.
+
+## Screen states
+
+| State | What |
+| --- | --- |
+| Success | Existing Home / Docs / Contribute / Community; browser tab title matches the route |
+| Not found | H1 “Page not found”, one sentence, primary “Home”, secondary “Docs” |
+| Loading / empty / offline | Unchanged marketing pages (static). No new loading chrome |
+
+## Components
+
+Reuse `SiteHeader`, `SiteFooter`, `.page` / `.docs-content` / `.btn`. No new design-system pattern except **Not found** (same as `.page`).
+
+## Copy (en)
+
+- Title (404): Page not found
+- Body: That address is not a LexAI page. Check the URL or go back home.
+- Primary: Home
+- Secondary: Docs
+
+Route titles/descriptions live in `src/seo.ts` (not on-page H1s).
+
+## Accessibility
+
+404 is a real `h1`; both actions are text links/buttons ≥ 40px; focus order follows visual order.
+
+## Acceptance
+
+1. `/docs`, `/contribute`, `/community` are path URLs (not `#/…`).
+2. `/robots.txt` allows `/` and names the sitemap.
+3. `/sitemap.xml` lists the four indexable URLs on `https://lexai.juankibin.space`.
+4. Each of those URLs has a unique `
` and meta description in the HTML file Google fetches.
+5. Unknown path shows the 404 copy; crawlers get HTTP 404.
+6. `#/docs` becomes `/docs`.
+
+## Out of scope
+
+Search Console verification token, sitemap ping, paid ads, i18n locales beyond en.
diff --git a/packages/website/README.md b/packages/website/README.md
index 2daee74..3d5dde0 100644
--- a/packages/website/README.md
+++ b/packages/website/README.md
@@ -28,7 +28,7 @@ Preview production build locally: `npm run website:preview` (Vite, localhost onl
Production start (what Coolify should run): `npm start` — serves `dist/` on `0.0.0.0:$PORT` (default 3000).
-Uses HashRouter so deep links work even without SPA rewrite rules. The production server still maps unknown paths to `index.html`.
+Uses BrowserRouter. Production nginx/Node serve `/docs`, `/contribute`, and `/community` as real paths (legacy `#/docs` links rewrite on load). Unknown paths return HTTP 404.
## Deploy on Coolify
@@ -44,7 +44,7 @@ This package is a static Vite build. No LexAI API keys or env vars. Do not add `
4. Branch: `dev` or `main`.
5. **Base Directory:** `packages/website` — no leading slash. Root `npm run build` builds Chrome, not this site.
6. Optional **Watch Paths:** `packages/website`.
-7. Set the domain, deploy, open `https://your-domain/` (docs: `https://your-domain/#/docs`).
+7. Set the domain, deploy, open `https://lexai.juankibin.space/` (docs: `/docs`).
### Option A — Dockerfile (recommended)
@@ -66,3 +66,13 @@ Do **not** tick "Is it a static site?" with Publish Directory `/dist`. The leadi
If you already created the resource: uncheck static site, set Base Directory and port as above, then Redeploy (no need to recreate the app).
Auto-deploy: Coolify resource → **Webhooks** → copy the URL + secret into Gitea **Settings → Webhooks** (push events).
+
+## Google Search
+
+After the site is live on `https://lexai.juankibin.space/`:
+
+1. Open [Google Search Console](https://search.google.com/search-console), add a URL-prefix property for that origin, and verify (HTML file or DNS).
+2. Sitemaps → submit `https://lexai.juankibin.space/sitemap.xml`.
+3. URL inspection → request indexing for `/` and `/docs`.
+
+Crawlers should see `Allow: /` at `/robots.txt` and unique titles on `/`, `/docs`, `/contribute`, and `/community`.
diff --git a/packages/website/SOCIAL_POSTS.md b/packages/website/SOCIAL_POSTS.md
index 6377299..3a355b3 100644
--- a/packages/website/SOCIAL_POSTS.md
+++ b/packages/website/SOCIAL_POSTS.md
@@ -30,7 +30,7 @@ Pick a pattern (role, chain-of-thought, and more), set persona and output format
Same BYO-key model: your API key, your provider, no LexAI servers.
Try it: https://lexai.juankibin.space/
-Docs: https://lexai.juankibin.space/#/docs
+Docs: https://lexai.juankibin.space/docs
#PromptEngineering #AI #OpenSource #DeveloperTools #BYOLLM
@@ -189,7 +189,7 @@ LexAI is an open-source toolkit for people who already have an LLM API key — a
No LexAI backend, account, or telemetry. Keys stay on-device (encrypted in Chrome, Secret Storage in VS Code).
Site: https://lexai.juankibin.space/
-Docs: https://lexai.juankibin.space/#/docs
+Docs: https://lexai.juankibin.space/docs
Repo: https://git.juankibin.space/kibin/LexAI
Chrome: https://chromewebstore.google.com/detail/bagpcheidbkfgijnnmolnkgagibbjfnk
VS Code: https://marketplace.visualstudio.com/items?itemName=JuanKibin.lexai-vscode
diff --git a/packages/website/index.html b/packages/website/index.html
index 956150c..323021e 100644
--- a/packages/website/index.html
+++ b/packages/website/index.html
@@ -3,10 +3,27 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -14,7 +31,28 @@
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
- LexAI — BYO-LLM writing & code assist
+ LexAI — BYO-LLM writing & code assist
+
diff --git a/packages/website/inject-seo.mjs b/packages/website/inject-seo.mjs
new file mode 100644
index 0000000..0e2d87c
--- /dev/null
+++ b/packages/website/inject-seo.mjs
@@ -0,0 +1,69 @@
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = dirname(fileURLToPath(import.meta.url));
+const distHtml = join(root, 'dist', 'index.html');
+const { origin, routes } = JSON.parse(
+ readFileSync(join(root, 'src', 'seo.routes.json'), 'utf8'),
+);
+
+function apply(html, { title, description, url, robots }) {
+ let out = html;
+ out = out.replace(/[\s\S]*?<\/title>/, `${title}`);
+ out = out.replace(
+ /(
+
+
+ https://lexai.juankibin.space/
+ weekly
+ 1.0
+
+
+ https://lexai.juankibin.space/docs
+ weekly
+ 0.8
+
+
+ https://lexai.juankibin.space/contribute
+ monthly
+ 0.5
+
+
+ https://lexai.juankibin.space/community
+ monthly
+ 0.5
+
+
diff --git a/packages/website/server.mjs b/packages/website/server.mjs
index 118f064..dd0c15d 100644
--- a/packages/website/server.mjs
+++ b/packages/website/server.mjs
@@ -20,8 +20,21 @@ const MIME = {
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
+ '.xml': 'application/xml; charset=utf-8',
};
+const SPA_ROUTES = new Set(['/', '/docs', '/contribute', '/community']);
+
+function requestPath(urlPath) {
+ const decoded = decodeURIComponent((urlPath ?? '/').split('?')[0] || '/');
+ if (decoded.length > 1 && decoded.endsWith('/')) return decoded.slice(0, -1) || '/';
+ return decoded || '/';
+}
+
+function isSpaRoute(urlPath) {
+ return SPA_ROUTES.has(requestPath(urlPath));
+}
+
function fileUnderRoot(urlPath) {
const decoded = decodeURIComponent((urlPath ?? '/').split('?')[0] || '/');
const relative = decoded.replace(/^\/+/, '').replaceAll('/', sep);
@@ -54,8 +67,14 @@ const server = createServer((req, res) => {
}
let file = candidate;
- if (!existsSync(file) || statSync(file).isDirectory()) {
- file = join(root, 'index.html');
+ let status = 200;
+ if (existsSync(file) && statSync(file).isDirectory()) {
+ const nested = join(file, 'index.html');
+ file = existsSync(nested) ? nested : join(root, 'index.html');
+ } else if (!existsSync(file)) {
+ const notFound = join(root, '404.html');
+ file = existsSync(notFound) && !isSpaRoute(req.url ?? '/') ? notFound : join(root, 'index.html');
+ if (!isSpaRoute(req.url ?? '/')) status = 404;
}
if (!existsSync(file)) {
send(res, 500, 'dist/ missing — run npm run build before start');
@@ -63,7 +82,7 @@ const server = createServer((req, res) => {
}
const type = MIME[extname(file).toLowerCase()] ?? 'application/octet-stream';
- res.writeHead(200, { 'Content-Type': type });
+ res.writeHead(status, { 'Content-Type': type });
if (req.method === 'HEAD') {
res.end();
return;
diff --git a/packages/website/src/App.tsx b/packages/website/src/App.tsx
index 1ba9445..07a6c5e 100644
--- a/packages/website/src/App.tsx
+++ b/packages/website/src/App.tsx
@@ -1,14 +1,17 @@
-import { Navigate, Route, Routes } from 'react-router-dom';
+import { Route, Routes } from 'react-router-dom';
+import { SeoHead } from './components/SeoHead';
import { SiteHeader } from './components/SiteHeader';
import { SiteFooter } from './components/SiteFooter';
import { HomePage } from './pages/HomePage';
import { DocsPage } from './pages/DocsPage';
import { ContributePage } from './pages/ContributePage';
import { CommunityPage } from './pages/CommunityPage';
+import { NotFoundPage } from './pages/NotFoundPage';
export default function App() {
return (