AiSalonHub maintains over 500 salon listings across the US, each with service menus, pricing tiers, location data, and operating hours. Manually writing blog posts for each location's service offerings is impossible. So we didn't. Instead, we built a schema-to-post pipeline that reads structured data from EmDash CMS, transforms it through a rule engine stored in Cloudflare D1, and publishes localized blog posts automatically — no human hands between schema change and published content. ## The Problem AiSalonHub's growth strategy depends on local SEO. Each salon listing needs supporting content — blog posts about "Best Nail Services in Downtown Austin" or "What to Expect at a Korean Skin Care Studio in Koreatown" — to rank for hyperlocal search queries. With 500+ listings and growing, the content requirements multiply: | Salon count | Posts needed per salon/month | Total monthly output | Manual capacity | |---|---|---|---| | 200 | 4 | 800 | ~40 (team of 2 writers) | | 350 | 4 | 1,400 | ~40 | | 500 | 4 | 2,000 | ~40 | Manual creation hits a hard ceiling at roughly 20 posts per writer per week. Even a dedicated team of 3–4 writers can only cover ~240–320 posts per month — a 75–84% shortfall at the 500-salon scale. The gap only widens as the listing base grows. Compounding the problem: each post requires location-specific details, service information that changes when salons update their menus, and local SEO elements (schema markup, neighborhood references, embedded maps). Manual writers cannot keep up with dynamic service data. A salon changes its pricing on Monday — the blog post written on Friday is already outdated. ## The Solution AiSalonHub's content calendar automation solves both the volume and freshness problems through a schema-driven pipeline that decouples content generation from human writing effort. The core insight: **the service data already exists in structured form**. Salons entering their menu into EmDash CMS create a rich schema of services, categories, prices, durations, and descriptions. Rather than asking writers to re-describe these services in prose, the automation pipeline reads the schema directly and generates natural-language posts from it. This is not simple token replacement. The pipeline uses: 1. **Schema-aware templates** — templates that understand entity relationships (a service belongs to a category, a category belongs to a salon, a salon has a location) 2. **Variation engines** — multiple template variants per content type to avoid duplicate content penalties 3. **Freshness tracking** — D1 stores the last-published hash of each service schema so only changed data triggers regeneration 4. **SEO enrichment** — auto-generated FAQ schema, location schema, and service schema that Google surfaces in rich results ## Architecture The automation pipeline flows through four layers: ``` EmDash CMS (seed schema) │ ▼ D1 Service Tables ┌──────────────────────────┐ │ services │ │ service_categories │ │ salons │ │ locations │ │ pricing_history │ └──────────────────────────┘ │ ▼ Content Rule Engine (D1) ┌──────────────────────────┐ │ content_templates │ │ content_queue │ │ generation_rules │ │ published_hashes │ └──────────────────────────┘ │ ▼ Cloudflare Worker (generator) │ ▼ D1 Content Tables ┌──────────────────────────┐ │ blog_posts │ │ post_metadata │ │ post_schema_blocks │ └──────────────────────────┘ │ ▼ Astro v6 Build (publish) ``` ### Layer 1: EmDash Seed Schema EmDash CMS stores salon data in structured collections. Each service entry includes: - `name`: "Gel Manicure" - `category_id`: references `service_categories` (e.g., "Nail Services") - `salon_id`: references the salon record - `price`: "$45" - `duration`: "45 min" - `description`: "Long-lasting gel polish application with shaping and cuticle care" - `features`: array of strings like ["24+ color options", "chip-resistant", "UV-cured"] When a salon updates their menu in EmDash, the CMS triggers a webhook to the Cloudflare Worker. No polling, no stale data. ### Layer 2: D1 Service Tables The webhook payload lands in D1 tables that mirror the EmDash schema but are optimized for content generation queries. The key table — `services` — is indexed by salon_id, category_id, and last_updated so the rule engine can efficiently query "what changed since last publish." ### Layer 3: Content Rule Engine This is the brains of the operation. The rule engine (stored in D1's `content_templates` and `generation_rules` tables) determines: - **When** to generate a post (schema change detected, weekly cadence for stale content, new salon added) - **What** type of post to generate (service guide, local roundup, FAQ, seasonal promotion) - **Which** template to use (varied by category, location, and content type) - **How** to assemble it (section ordering, internal link insertion, CTA placement) Each rule is a JSON document evaluated by the Worker: ```json { "trigger": "schema_change", "entity_type": "service", "post_type": "service_guide", "template_id": "nail-services-guide-v3", "conditions": { "min_services_in_category": 3 }, "schedule": { "publish_delay_minutes": 15, "max_per_week": 10 } } ``` ### Layer 4: Content Queue + Generation The rule engine writes entries into `content_queue` with priority scores. A Cloudflare Worker cron job (running every 15 minutes) picks up the highest-priority unprocessed items, applies the template, inserts SEO metadata, and writes the completed post to `blog_posts`. The generation itself is template-based with variable interpolation and conditional sections. Here's a simplified example: ```markdown ## {service_category} at {salon_name} Located at {salon_address}, {salon_name} offers {service_count} different {category_keyword_phrase}. Whether you're looking for {service_example_1} or {service_example_2}, their team of {staff_count} professionals covers everything from {price_range_min} to {price_range_max}. ### Services Offered | Service | Price | Duration | |---|---|---| {for service in services} | {service.name} | {service.price} | {service.duration} | {end for} {for feature in category_features} - {feature.description} {end for} ``` The template engine supports multiple variants per content type. For a "nail services guide" post, there are 8 template variants — each with different introduction styles, section ordering, and phrasing patterns — randomly selected to ensure output diversity across 500+ locations. ### Layer 5: D1 Publish + Astro Build Once the post is generated and stored in D1's `blog_posts` table, it carries a `status: draft` flag. A scheduled Astro v6 build process queries D1 for all `status: ready` posts, generates static pages, and deploys to Cloudflare Pages. The entire pipeline — from schema update to live URL — completes in under 30 minutes. ## Implementation ### Step 1: Schema Definition We defined the EmDash CMS collections with content generation in mind. Every field includes a `description_for_ai` metadata property that tells the template engine how to use that field in prose. For example, "duration": {"value": "45 min", "description_for_ai": "Used in service descriptions like 'a 45-minute gel manicure appointment'"}. ### Step 2: Data Extraction via Webhook When a salon updates its services in EmDash, a webhook POSTs the full service payload to a Cloudflare Worker endpoint. The Worker validates, normalizes (ensures consistent category naming), and upserts into D1: ```javascript export default { async fetch(request, env) { const payload = await request.json(); const { salon_id, services } = payload; // Upsert services const stmt = env.DB.prepare(` INSERT INTO services (salon_id, name, category_id, price, duration, features, description, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(salon_id, name) DO UPDATE SET price = excluded.price, duration = excluded.duration, features = excluded.features, description = excluded.description, updated_at = datetime('now') `); for (const service of services) { await stmt.bind(salon_id, service.name, service.category_id, service.price, service.duration, JSON.stringify(service.features), service.description).run(); } // Queue content generation await env.DB.prepare(` INSERT INTO content_queue (salon_id, trigger_type, priority, created_at) VALUES (?, 'schema_change', 10, datetime('now')) `).bind(salon_id).run(); return new Response("OK", { status: 200 }); } } ``` ### Step 3: Template Assembly The generation worker reads the highest-priority queue item, fetches all related data (salon info, services, categories, pricing tiers), selects a template variant using a stable hash of the salon_id + date to ensure consistency within a day, and interpolates the data: ```javascript async function generatePost(salonId, env) { const salon = await env.DB.prepare( "SELECT * FROM salons WHERE id = ?" ).bind(salonId).first(); const services = await env.DB.prepare( "SELECT s.*, sc.name as category_name FROM services s " + "JOIN service_categories sc ON s.category_id = sc.id " + "WHERE s.salon_id = ? ORDER BY sc.name" ).bind(salonId).all(); const templateVariant = selectVariant(salonId, services.results.length); const body = renderTemplate(templateVariant, { salon, services: services.results }); await env.DB.prepare(` INSERT INTO blog_posts (salon_id, title, body_text, excerpt, status, created_at) VALUES (?, ?, ?, ?, 'ready', datetime('now')) `).bind(salonId, generateTitle(salon, services.results), body, generateExcerpt(salon)).
Key Takeaways
- **Your CMS schema is a content factory** — The same EmDash schema that powers your admin UI can drive an automated publishing pipeline. Every field in your schema is a variable waiting to be templated.
- **Start with structured data** — Schema-driven content needs structured inputs. Define your content types with EmDash's repeater and JSON fields before building the automation layer.
- **Multi-format generation multiplies ROI** — One structured dataset can generate comparison pages, FAQs, schema markup, and social snippets simultaneously.
- **Cloudflare Workers make serverless content automation practical** — With D1, R2, and Workers, you can build a fully serverless content engine that costs pennies per month.
- **Validate before publishing** — Always check word count, schema conformance, and slug uniqueness before inserting into D1. Automation is powerful, but guardrails keep it from going rogue.