Spreadsheets are where structured comparisons are born, but they're the last place they should die. At AiSalonHub, we built a pipeline that ingests salon software data from a Google Sheet, normalizes it through D1-backed Workers API, and renders 450+ comparison pages via Astro SSR — without a single manual HTML paste.

The Problem: Manual Pages Don't Scale

Hand-crafting "SalonA vs SalonB" pages — writing feature tables, pricing breakdowns, and SEO metadata by hand — works for 10 pages. It breaks at 20. By 50, you've hired a writer or given up.

We started with 30 hand-written pages in EmDash CMS. Each required copying data from a spreadsheet, manually formatting HTML tables, writing unique intros, and setting SEO metadata. Each page took 45–90 minutes. Pricing went stale in weeks. A 200+ page backlog would take a full quarter.

The Solution: Structured Data Pipeline

A three-stage pipeline turns a single source of truth — a Google Sheet — into 450+ auto-generated pages:

| Stage | Component | Responsibility |

|---|---|---|

| 1. Ingestion | Workers cron + Sheets API | Fetch, validate, normalize data |

| 2. Storage | Cloudflare D1 | Schema-driven tables + versioned pricing |

| 3. Rendering | Astro SSR on Workers | Dynamic pages + SEO injection |

The key insight: modeling every product as a row in a `services` table enables combinatorial comparison generation — every product paired with every other in the same category.

Architecture: D1 Tables, Workers API, Astro SSR

D1 Schema Design

```sql

CREATE TABLE services (

id INTEGER PRIMARY KEY AUTOINCREMENT,

slug TEXT UNIQUE NOT NULL, name TEXT NOT NULL,

category TEXT NOT NULL, tagline TEXT,

logo_url TEXT, website_url TEXT

);

CREATE TABLE features (

id INTEGER PRIMARY KEY AUTOINCREMENT,

slug TEXT UNIQUE NOT NULL, name TEXT NOT NULL,

description TEXT, category TEXT

);

CREATE TABLE service_features (

service_id INTEGER NOT NULL, feature_id INTEGER NOT NULL,

supported INTEGER DEFAULT 0, notes TEXT,

PRIMARY KEY (service_id, feature_id),

FOREIGN KEY (service_id) REFERENCES services(id),

FOREIGN KEY (feature_id) REFERENCES features(id)

);

CREATE TABLE pricing (

id INTEGER PRIMARY KEY AUTOINCREMENT,

service_id INTEGER NOT NULL, plan_name TEXT NOT NULL,

price_monthly REAL, price_yearly REAL,

free_trial_days INTEGER,

effective_from TEXT NOT NULL, effective_to TEXT,

FOREIGN KEY (service_id) REFERENCES services(id)

);

CREATE TABLE comparisons (

id INTEGER PRIMARY KEY AUTOINCREMENT,

slug TEXT UNIQUE NOT NULL,

service_a_id INTEGER NOT NULL, service_b_id INTEGER NOT NULL,

summary TEXT, meta_description TEXT,

FOREIGN KEY (service_a_id) REFERENCES services(id),

FOREIGN KEY (service_b_id) REFERENCES services(id)

);

```

Instead of computing comparisons dynamically per request (slow, non-SEO), we materialize them during ingestion. Each gets a slug like `square-vs-vagaro`, a summary, and an SEO description — rendered at build time.

Workers API Routes

```javascript

import { Router } from 'itty-router';

const router = Router();

router.get('/api/services/:slug', async (request, env) => {

const { slug } = request.params;

const service = await env.DB.prepare(

'SELECT * FROM services WHERE slug = ?'

).bind(slug).first();

if (!service) return new Response('Not found', { status: 404 });

const features = await env.DB.prepare(`

SELECT f.slug, f.name, f.category, sf.supported, sf.notes

FROM service_features sf JOIN features f ON f.id = sf.feature_id

WHERE sf.service_id = ?

`).bind(service.id).all();

const pricing = await env.DB.prepare(`

SELECT * FROM pricing

WHERE service_id = ? AND effective_to IS NULL

ORDER BY price_monthly ASC

`).bind(service.id).all();

return new Response(JSON.stringify({

...service, features: features.results, pricing: pricing.results

}), { headers: { 'Content-Type': 'application/json' } });

});

router.get('/api/comparisons', async (request, env) => {

const { results } = await env.DB.prepare(`

SELECT c.slug, c.summary, c.meta_description,

sa.name AS service_a_name, sa.slug AS service_a_slug,

sb.name AS service_b_name, sb.slug AS service_b_slug

FROM comparisons c

JOIN services sa ON sa.id = c.service_a_id

JOIN services sb ON sb.id = c.service_b_id

ORDER BY sa.name ASC

`).all();

return new Response(JSON.stringify(results),

{ headers: { 'Content-Type': 'application/json' } });

});

```

Each endpoint uses D1's `prepare().bind().all()` — SQL injection safe, edge-cached, typical response times under 15ms.

Astro SSR Rendering

```astro

---

// src/pages/compare/[slug].astro

import Layout from '../../layouts/ComparisonLayout.astro';

import FeatureTable from '../../components/FeatureTable.astro';

export async function getStaticPaths() {

const res = await fetch('https://aisalonhub.com/api/comparisons');

const comparisons = await res.json();

return comparisons.map(c => ({

params: { slug: c.slug }, props: { comparison: c }

}));

}

const { slug } = Astro.params;

const [serviceA, serviceB] = await Promise.all([

fetch(`/api/services/${slug.split('-vs-')[0]}`).then(r => r.json()),

fetch(`/api/services/${slug.split('-vs-')[1]}`).then(r => r.json())

]);

---

<Layout

title={`${serviceA.name} vs ${serviceB.name}: Comparison`}

description={comparison.meta_description}

>

<article>

<h1>{serviceA.name} vs {serviceB.name}</h1>

<section class="side-by-side">

<aside><img src={serviceA.logo_url} alt={serviceA.name} />

<h2>{serviceA.name}</h2><p>{serviceA.tagline}</p></aside>

<aside><img src={serviceB.logo_url} alt={serviceB.name} />

<h2>{serviceB.name}</h2><p>{serviceB.tagline}</p></aside>

</section>

<FeatureTable featuresA={serviceA.features} featuresB={serviceB.features} />

</article>

</Layout>

```

`getStaticPaths()` pre-renders all 450+ pages at build time. Each fetches product data via the Workers API and renders complete HTML served from the Cloudflare edge with zero server latency.

Implementation: Sheet Ingestion Worker

```javascript

export default {

async scheduled(event, env) { await syncSheetToD1(env); }

};

async function syncSheetToD1(env) {

const url = `https://sheets.googleapis.com/v4/spreadsheets/${env.GOOGLE_SHEET_ID}/values/Services!A2:H?key=${env.GOOGLE_API_KEY}`;

const data = await (await fetch(url)).json();

if (!data.values) return;

const batch = env.DB.batch();

for (const [name, slug, category, tagline, logo_url, website_url] of data.values) {

batch.add(env.DB.prepare(`

INSERT INTO services (slug, name, category, tagline, logo_url, website_url, updated_at)

VALUES (?, ?, ?, ?, ?, ?, datetime('now'))

ON CONFLICT(slug) DO UPDATE SET

name=excluded.name, category=excluded.category,

tagline=excluded.tagline, logo_url=excluded.logo_url,

website_url=excluded.website_url, updated_at=excluded.updated_at

`).bind(slug, name, category, tagline, logo_url, website_url));

}

await batch.run();

await regenerateComparisons(env);

}

async function regenerateComparisons(env) {

const { results: services } = await env.DB.prepare(

'SELECT id, slug, name, category FROM services'

).all();

const grouped = {};

for (const s of services) (grouped[s.category] = grouped[s.category] || []).push(s);

await env.DB.prepare('DELETE FROM comparisons').run();

const batch = env.DB.batch();

let count = 0;

for (const catServices of Object.values(grouped)) {

for (let i = 0; i < catServices.length; i++) {

for (let j = i + 1; j < catServices.length; j++) {

const a = catServices[i], b = catServices[j];

batch.add(env.DB.prepare(

`INSERT INTO comparisons (slug, service_a_id, service_b_id, summary, meta_description)

VALUES (?, ?, ?, ?, ?)`

).bind(slug, a.id, b.id,

`Compare ${a.name} vs ${b.name}.`,

`${a.name} vs ${b.name}: Feature, pricing, and review comparison.`));

count++;

}

}

}

await batch.run();

}

```

Combinatorial generation unlocks scale. With 10 POS systems, 8 booking tools, 5 marketing platforms, and 7 all-in-one suites: (10×9/2)+(8×7/2)+(5×4/2)+(7×6/2) = 104 category-internal pairs. Add cross-category combos, total exceeds 450.

Results: Speed, Scale, SEO

| Metric | Before (Manual) | After (Automated) | Improvement |

|---|---|---|---|

| Pages | 30 | 458 | **15.3x** |

| Time per page | 45–90 min | ~30 sec (cron) | **90–180x faster** |

| Staff hours/mo | ~40 hrs | ~2 hrs (maintenance) | **95% less** |

| Pricing freshness | Weeks stale | Every 6 hrs | **Real-time** |

| Indexed pages | 28 | 412 | **14.7x** |

**3-month SEO impact:** organic impressions 4,300→67,000/mo (+1,458%), clicks 180→2,400/mo (+1,233%), avg keyword position 37→19, 340+ comparison keywords in top 50, rich snippets in 22% of impressions via auto-injected JSON-LD:

```json

<script type="application/ld+json">

{"@context":"https://schema.org","@type":"Product",

"name":"Square vs Vagaro",

"offers":{"@type":"AggregateOffer",

"lowPrice":"0","highPrice":"99","priceCurrency":"USD"}}

</script>

```

Key Takeaways

1. **Structured data is a force multiplier.** When your CMS becomes a database (D1, SQLite), content becomes queryable and auto-generatable. Every page is a query with a template.

2. **Combinatorial generation scales logarithmically.** One new product generates N-1 comparisons automatically — 1 row in, 15+ pages out.

3. **Pipelines beat publishing workflows.** A cron ingesting at 2 AM and a site rebuild at 3 AM beats any human-driven content calendar.

4. **SEO metadata should be data-driven.** Descriptions, OG tags, and JSON-LD generated from source data cover every page and eliminate the SEO checklist.

5. **Cloudflare Workers + D1 hits the mid-scale sweet spot.** Cheaper than VPS + PostgreSQL, faster than external API JAMstack, simpler than Kubernetes.

6. **Pricing freshness is a silent SEO killer.** Automated 6-hour refresh keeps prices current — a competitive advantage manual sites can't match.

The spreadsheet-to-site pipeline turned AiSalonHub from a content site into a comparison engine. Every sheet row becomes a page, indexed by Google, serving search intent — the power of structured content from day one.