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.