diff --git a/docs/CMS.md b/docs/CMS.md
index 970ea24..a2c42d6 100644
--- a/docs/CMS.md
+++ b/docs/CMS.md
@@ -1,181 +1,294 @@
# CMS & Programmatic SEO
-How the content management system works: template definitions, bulk article generation, scenario embedding, the build pipeline, and how content is discovered and served.
+How the content management system works: git-based template definitions, DuckDB data sources, bulk article generation, scenario embedding, SEO pipeline, and how content is served.
---
## Overview
-The CMS is a **template-driven, programmatic content system**. The core idea:
+The CMS is an **SSG-inspired programmatic content system**. The core idea:
-1. Define a **template** — URL pattern, title pattern, meta description pattern, and a Jinja2 body
-2. Upload **data rows** (CSV or single entry via admin UI) that fill the template variables
-3. **Generate** — the system runs the financial calculator, writes HTML to disk, and inserts `articles` rows
-4. Articles are **served** by a catch-all route that looks up the URL path in the database
+1. **Templates live in git** — `.md.jinja` files with YAML frontmatter define how articles look
+2. **Data lives in DuckDB** — one serving table per pSEO idea provides what articles say
+3. **Generation** renders templates with data, bakes in SEO metadata, writes HTML to disk
+4. **SQLite** stores routing state only — `articles` and `published_scenarios` tables
+5. Articles are **served** by a catch-all route reading pre-built HTML from disk
-This is designed for scale: one template definition can produce hundreds of SEO-targeted articles.
+```
+ Git repo (content shape) DuckDB (content data) SQLite (routing/state)
+ ──────────────────────── ───────────────────── ──────────────────────
+ content/templates/ serving.pseo_city_costs_de articles
+ city-cost-de.md.jinja → city_slug, population, → url_path, slug
+ market-compare.md.jinja venue_count, rates... → status, published_at
+ → template_slug
+ serving.pseo_market_compare
+ "How articles look" "What articles say" published_scenarios
+ → slug, state_json
+ → calc_json
+ "Where things live"
+```
+
+---
+
+## Template file format
+
+Templates are `.md.jinja` files in `web/src/padelnomics/content/templates/`. Each file has YAML frontmatter delimited by `---`, followed by a Jinja2 Markdown body.
+
+### Example
+
+```markdown
+---
+name: "DE City Padel Costs"
+slug: city-cost-de
+content_type: calculator
+data_table: serving.pseo_city_costs_de
+natural_key: city_slug
+languages: [de, en]
+url_pattern: "/markets/{{ country_name_en | lower | slugify }}/{{ city_slug }}"
+title_pattern: "Padel in {{ city_name }} — Market Analysis & Costs"
+meta_description_pattern: "How much does it cost to build a padel center in {{ city_name }}? {{ padel_venue_count }} venues, pricing data & financial model."
+schema_type: [Article, FAQPage]
+priority_column: population
+---
+# Padel in {{ city_name }}
+
+{{ city_name }} has {{ padel_venue_count }} padel venues...
+
+[scenario:{{ scenario_slug }}:capex]
+
+## FAQ
+
+**How much does a padel court cost in {{ city_name }}?**
+Based on our financial model, a {{ courts_typical }}-court center requires...
+```
+
+### Frontmatter fields
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| `name` | Yes | Human-readable template name (shown in admin) |
+| `slug` | Yes | URL-safe identifier, also the filename stem |
+| `content_type` | Yes | `calculator` (has financial scenario) or `editorial` |
+| `data_table` | Yes | DuckDB serving table, schema-qualified (e.g. `serving.pseo_city_costs_de`) |
+| `natural_key` | Yes | Column used as unique row identifier (e.g. `city_slug`) |
+| `languages` | Yes | List of language codes to generate (e.g. `[de, en]`) |
+| `url_pattern` | Yes | Jinja2 pattern for article URL path |
+| `title_pattern` | Yes | Jinja2 pattern for `
` and `og:title` |
+| `meta_description_pattern` | Yes | Jinja2 pattern for meta description (aim for 120-155 chars) |
+| `schema_type` | No | JSON-LD type(s): `Article` (default), `FAQPage`, or list. See [Structured data](#structured-data) |
+| `priority_column` | No | Column to sort by for publish order (highest value first) |
+| `related_template_slugs` | No | Other template slugs for cross-linking (future) |
+
+### Body template
+
+Everything after the closing `---` is the body. It's Jinja2 Markdown with access to:
+
+- **All columns** from the DuckDB row (e.g. `{{ city_name }}`, `{{ population }}`)
+- **`language`** — current language code (`en`, `de`)
+- **`scenario_slug`** — auto-derived slug for `[scenario:]` markers (calculator type only)
+
+The `slugify` filter is available: `{{ country_name_en | slugify }}`.
---
## Database schema
-```
-articles — Published articles (the SEO output)
-article_templates — Template definitions (patterns + Jinja2 body)
-template_data — One row per article to be generated; links template → article
-published_scenarios — Calculator results embedded in article bodies
-articles_fts — FTS5 virtual table for full-text search over articles
-```
+After migration 0018, only two content tables remain in SQLite:
### articles
| Column | Type | Notes |
|--------|------|-------|
-| `id` | INTEGER PK | |
-| `url_path` | TEXT UNIQUE | Matches `request.path`, e.g. `/markets/de/berlin` |
-| `slug` | TEXT UNIQUE | Used for the build HTML filename |
-| `title` | TEXT | |
-| `meta_description` | TEXT | |
-| `country` | TEXT | Used for grouping in the markets hub |
-| `region` | TEXT | Used for grouping in the markets hub |
-| `og_image_url` | TEXT | |
+| `url_path` | TEXT UNIQUE | e.g. `/en/markets/germany/berlin` |
+| `slug` | TEXT UNIQUE | e.g. `city-cost-de-en-berlin` |
+| `title` | TEXT | Rendered from `title_pattern` |
+| `meta_description` | TEXT | Rendered from `meta_description_pattern` |
+| `country` | TEXT | For markets hub grouping |
+| `region` | TEXT | For markets hub grouping |
| `status` | TEXT | `'published'` or `'draft'` |
-| `published_at` | DATETIME | Future dates suppress the article until then |
-| `template_data_id` | INTEGER FK | Back-reference to `template_data.id` |
+| `published_at` | DATETIME | Future dates suppress the article |
+| `template_slug` | TEXT | Links back to git template for regeneration |
+| `language` | TEXT | Language code (`en`, `de`) for hreflang |
+| `date_modified` | TEXT | ISO timestamp, updated on regeneration |
+| `seo_head` | TEXT | Pre-computed HTML: canonical, hreflang, JSON-LD, OG tags |
-FTS5 virtual table `articles_fts` is kept in sync with `title`, `meta_description`, `country`, `region`.
-
-### article_templates
-
-| Column | Type | Notes |
-|--------|------|-------|
-| `id` | INTEGER PK | |
-| `name` | TEXT | Human label shown in admin |
-| `slug` | TEXT UNIQUE | |
-| `content_type` | TEXT | e.g. `'market'` |
-| `input_schema` | JSON | Array of field definitions; drives the admin form |
-| `url_pattern` | TEXT | Jinja2, e.g. `/markets/{{ country \| lower }}/{{ city_slug }}` |
-| `title_pattern` | TEXT | Jinja2, e.g. `{{ city }} Padel Court Economics` |
-| `meta_description_pattern` | TEXT | Jinja2 |
-| `body_template` | TEXT | Jinja2 Markdown with optional `[scenario:slug]` markers |
-
-### template_data
-
-| Column | Type | Notes |
-|--------|------|-------|
-| `id` | INTEGER PK | |
-| `template_id` | INTEGER FK | → `article_templates.id` |
-| `data_json` | JSON | The variables that fill the template |
-| `scenario_id` | INTEGER FK | → `published_scenarios.id` (set after generation) |
-| `article_id` | INTEGER FK | → `articles.id` (set after generation) |
-| `created_at` | DATETIME | |
-| `updated_at` | DATETIME | |
+FTS5 virtual table `articles_fts` syncs `title`, `meta_description`, `country`, `region` via triggers.
### published_scenarios
-Pre-calculated financial scenarios embedded in articles.
-
| Column | Type | Notes |
|--------|------|-------|
-| `id` | INTEGER PK | |
| `slug` | TEXT UNIQUE | Referenced in `[scenario:slug]` markers |
-| `title` | TEXT | |
-| `subtitle` | TEXT | |
+| `title` | TEXT | City name |
| `location` | TEXT | City name |
| `country` | TEXT | |
-| `venue_type` | TEXT | |
-| `ownership` | TEXT | |
-| `court_config` | TEXT | |
+| `venue_type` | TEXT | `indoor` / `outdoor` |
+| `ownership` | TEXT | `rent` / `own` |
+| `court_config` | TEXT | e.g. `4 double + 2 single` |
| `state_json` | JSON | Calculator input state |
| `calc_json` | JSON | Calculator output (all sections) |
-| `template_data_id` | INTEGER FK | |
---
-## Programmatic SEO pipeline
+## Generation pipeline
-### Step 1 — Define a template
+### Full flow
-In admin → Templates → New Template.
+```
+ 1. Create serving model in SQLMesh
+ models/serving/pseo_city_costs_de.sql → sqlmesh plan prod → analytics.duckdb
-**URL pattern** uses Jinja2 and must produce a unique, clean path:
+ 2. Create template file in git
+ content/templates/city-cost-de.md.jinja
-```jinja2
-/markets/{{ country | lower }}/{{ city_slug }}
+ 3. Preview locally
+ Admin → Templates → pick template → pick city → preview rendered article
+
+ 4. Commit + push + deploy
+
+ 5. Admin → Templates → "DE City Padel Costs"
+ sees data_table, row count from DuckDB, generated count
+
+ 6. Click "Generate"
+ start_date, articles_per_day → for each DuckDB row × language:
+ • render URL, title, meta patterns
+ • create/update published_scenario (calculator type)
+ • render body.md.jinja → Markdown → HTML
+ • bake [scenario:] markers into HTML
+ • inject SEO head (canonical, hreflang, JSON-LD, OG)
+ • write HTML to data/content/_build/{lang}/{slug}.html
+ • upsert article row in SQLite
+ • stagger published_at dates
+
+ 7. Articles live at /en/markets/germany/berlin
+ with links to /planner?scenario=city-cost-de-berlin
+
+ 8. Data refresh → "Regenerate" in admin
+ reads fresh DuckDB data, updates HTML + scenarios in-place
```
-**Title and meta description patterns** are also Jinja2:
+### What happens per row
-```jinja2
-{{ city }} Padel Court Business Plan — {{ court_count }}-Court Indoor Venue
-Annual revenue and ROI analysis for a {{ court_count }}-court padel venue in {{ city }}, {{ country }}.
-```
+For each DuckDB row × each language in `config.languages`:
-**Body template** is Jinja2 Markdown. Access any field from `data_json`, plus the generated scenario's slug via `scenario_slug`. Use `[scenario:slug]` markers to embed calculator sections:
+1. Render `url_pattern`, `title_pattern`, `meta_description_pattern` with row data
+2. If `content_type == "calculator"`: extract calc fields → `validate_state()` → `calc()` → upsert `published_scenarios`
+3. Render body template with row data + `scenario_slug`
+4. Convert Markdown → HTML via `mistune`
+5. Replace `[scenario:slug:section]` markers with rendered HTML cards
+6. Build SEO head: canonical, hreflang links, JSON-LD, OG tags
+7. Write HTML to `data/content/_build/{lang}/{article_slug}.html`
+8. Upsert `articles` row in SQLite
+9. Stagger `published_at` based on `articles_per_day`
-```markdown
-## {{ city }} Market Overview
+### Scenario markers
-A {{ court_count }}-court indoor venue in {{ city }} has the following economics:
-
-[scenario:{{ scenario_slug }}]
-
-### Capital Expenditure
-
-[scenario:{{ scenario_slug }}:capex]
-
-### Annual Cash Flow
-
-[scenario:{{ scenario_slug }}:cashflow]
-```
-
-**Scenario section options** for the `[scenario:slug:section]` syntax:
+Use `[scenario:slug]` or `[scenario:slug:section]` in the body template:
| Section | Shows |
|---------|-------|
-| _(none)_ | Default summary card |
+| _(none)_ | Default summary card with CTA link to planner |
| `capex` | Capital expenditure breakdown |
| `operating` | Operating cost breakdown |
-| `cashflow` | Annual cash flow |
+| `cashflow` | Annual cash flow projection |
| `returns` | ROI / returns summary |
-| `full` | All sections |
+| `full` | All sections combined |
-### Step 2 — Upload data
+Always use `{{ scenario_slug }}` — never hardcode a slug.
-Admin → Templates → (template) → Data → Upload CSV, or add single rows via the form.
+### Scenario slug derivation
-The CSV columns must match the template's `input_schema`. Each row becomes one `template_data` record and will generate one article.
-
-### Step 3 — Generate
-
-Admin → Templates → (template) → Generate.
-
-**What happens for each data row:**
-
-1. Merge row data with calculator DEFAULTS
-2. Run financial calculator → produce `state_json` + `calc_json`
-3. Create a `published_scenarios` record with an auto-derived slug
-4. Render Jinja2 patterns (URL, title, meta description, body) using row data + scenario slug
-5. Convert body Markdown → HTML via `mistune`
-6. Replace `[scenario:slug(:section)?]` markers with rendered HTML partials
-7. Write final HTML to `data/content/_build/{slug}.html`
-8. Back up Markdown source to `data/content/articles/{slug}.md`
-9. Create an `articles` record
-10. Update `template_data` row with `scenario_id` and `article_id`
-
-**Staggered publishing:** the `articles_per_day` parameter spreads `published_at` dates so articles drip out over time rather than all appearing at once.
-
-### Step 4 — Serve
-
-The `content` blueprint registers a catch-all route **last**, so it only fires if no other blueprint matched:
-
-```python
-@bp.route("/")
-async def article_page(url_path: str):
- ...
+```
+scenario_slug = template_slug + "-" + natural_key_value
```
-Reserved prefixes are short-circuited to avoid conflicts:
+Example: template `city-cost-de` + row `city_slug=berlin` → `city-cost-de-berlin`
+
+### Article slug derivation
+
+```
+article_slug = template_slug + "-" + language + "-" + natural_key_value
+```
+
+Example: `city-cost-de-en-berlin`, `city-cost-de-de-berlin`
+
+---
+
+## SEO pipeline
+
+All SEO metadata is baked into the `seo_head` column at generation time. No runtime computation.
+
+### URL structure
+
+```
+/{lang}/markets/{country_name}/{city_slug}
+```
+
+- Language prefix for hreflang (`/de/...`, `/en/...`)
+- Full country name in path (`/germany/`, not `/de/`) — avoids confusion with lang prefix
+- `slugify` filter converts to lowercase hyphenated form
+
+### Hreflang
+
+For each article, links to all language variants + `x-default` (points to English):
+
+```html
+
+
+
+```
+
+### Canonical URLs
+
+Self-referencing canonical on every article:
+
+```html
+
+```
+
+### Structured data
+
+JSON-LD `