Skip to main content

Component Builder Guide

SpiderPublish components are reusable UI blocks that render inside Declarative Shadow DOM — CSS and JS are fully isolated per component. No CSS leaks, no naming collisions, no matter which agent or developer built them.

Components support 4 interactivity tiers. Each tier builds on the previous one:

TierNameWhat You Add
1Statichtml_template + css
2Interactive+ js (vanilla, scoped to shadow root)
3Rich+ dependencies (CDN libraries like GSAP, Chart.js)
4App+ framework + source_code (React/Vue/Svelte)

For a detailed comparison of tiers, see the Tiers Reference. For a machine-readable reference optimized for AI agents, see the Agent Reference.


Quick Start: Static Component (Tier 1)

Create a hero section component with a headline and CTA button. No JavaScript — pure HTML + CSS rendered in Shadow DOM.

Step 1: Create the Component

curl -X POST "https://spideriq.ai/api/v1/dashboard/content/components" \
-H "Authorization: Bearer $CLIENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "hero-gradient",
"name": "Gradient Hero",
"category": "hero",
"version": "1.0.0",
"html_template": "<section class=\"hero\">\n <h1>{{ props.headline }}</h1>\n <p>{{ props.subheadline }}</p>\n <a href=\"{{ props.cta_url }}\" class=\"btn\">{{ props.cta_text }}</a>\n</section>",
"css": ":host { display: block; }\n.hero { background: linear-gradient(135deg, var(--primary), #000); padding: 5rem 2rem; text-align: center; color: white; }\nh1 { font-size: 3rem; margin: 0 0 1rem; }\np { font-size: 1.25rem; opacity: 0.85; margin: 0 0 2rem; }\n.btn { display: inline-block; padding: 0.75rem 2rem; background: white; color: var(--primary); border-radius: 0.5rem; text-decoration: none; font-weight: 600; }",
"props_schema": {
"type": "object",
"properties": {
"headline": { "type": "string", "title": "Headline" },
"subheadline": { "type": "string", "title": "Subheadline" },
"cta_text": { "type": "string", "title": "Button Text" },
"cta_url": { "type": "string", "title": "Button URL" }
},
"required": ["headline"]
},
"default_props": {
"subheadline": "Build something great.",
"cta_text": "Get Started",
"cta_url": "/signup"
}
}'

Step 2: Publish

curl -X POST "https://spideriq.ai/api/v1/dashboard/content/components/{id}/publish" \
-H "Authorization: Bearer $CLIENT_TOKEN"

Step 3: Use in a Page Block

Add the component to any page's blocks array:

{
"id": "hero-1",
"type": "component",
"component_slug": "hero-gradient",
"props": {
"headline": "Ship Faster with AI",
"cta_text": "Start Free Trial",
"cta_url": "/trial"
}
}

Props you don't provide fall back to default_props — in this case subheadline defaults to "Build something great."

Theme Integration

Your CSS can use var(--primary) — SpiderPublish automatically injects your site's primary_color as a CSS variable into every component's :host selector.


Adding Interactivity: Scoped JS (Tier 2)

Set the js field to add vanilla JavaScript that runs inside the shadow root. The JS receives two arguments:

  • root — the shadow root element (use root.querySelector() to find elements)
  • props — the merged props object (page block props + defaults)

Example: FAQ Accordion

curl -X POST "https://spideriq.ai/api/v1/dashboard/content/components" \
-H "Authorization: Bearer $CLIENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "faq-accordion",
"name": "FAQ Accordion",
"category": "faq",
"html_template": "<div class=\"faq\">{% for item in props.items %}<div class=\"faq-item\"><button class=\"faq-q\">{{ item.question }}</button><div class=\"faq-a\" hidden>{{ item.answer }}</div></div>{% endfor %}</div>",
"css": ".faq-q { display: block; width: 100%; text-align: left; padding: 1rem; background: none; border: none; border-bottom: 1px solid #333; color: inherit; font-size: 1rem; cursor: pointer; font-family: var(--font-body, system-ui); }\n.faq-q:hover { background: rgba(255,255,255,0.05); }\n.faq-a { padding: 1rem; line-height: 1.6; }\n.faq-a[hidden] { display: none; }",
"js": "root.querySelectorAll(\".faq-q\").forEach(btn => { btn.addEventListener(\"click\", () => { const answer = btn.nextElementSibling; const isOpen = !answer.hidden; root.querySelectorAll(\".faq-a\").forEach(a => a.hidden = true); answer.hidden = isOpen; }); });",
"props_schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"question": { "type": "string" },
"answer": { "type": "string" }
}
}
}
},
"required": ["items"]
}
}'
JS Scoping Rules
  • Always use root.querySelector() — never document.querySelector()
  • Your JS has no access to the parent DOM outside the shadow root
  • No Tailwind — write plain CSS, it's scoped to this component only
  • The root parameter IS the shadowRoot element

How It Works Under the Hood

  1. Your JS is stored as <script type="spideriq/component-js"> inside the shadow template (non-executable type)
  2. A ~200-byte hydration script is injected once before </body>
  3. The hydration script finds all components with JS, reads the script content, and executes it via new Function('root','props', code)(shadowRoot, mergedProps)
  4. Each component gets its own execution scope — no collisions between components

Using CDN Libraries (Tier 3)

Need GSAP animations, Chart.js charts, or Swiper carousels? Add the dependencies field with keys from the CDN allowlist.

Discover Available Libraries

curl "https://spideriq.ai/api/v1/content/cdn-allowlist"
KeyLibraryCategoryDescription
gsapGSAP CoreanimationGreenSock animation library
gsap/ScrollTriggerGSAP ScrollTriggeranimationScroll-triggered animations
gsap/FlipGSAP FlipanimationLayout transition animations
animejsanime.jsanimationLightweight animation library
alpinejsAlpine.jsframeworkMinimal reactive framework
chartjsChart.jsvisualizationCanvas charting library
lottieLottie WebanimationAfter Effects animation player
swiperSwipercarouselTouch slider/carousel
countupCountUp.jsanimationAnimated number counter
threeThree.js3dWebGL 3D library

Example: Animated Stats Bar with GSAP ScrollTrigger

curl -X POST "https://spideriq.ai/api/v1/dashboard/content/components" \
-H "Authorization: Bearer $CLIENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "stats-animated",
"name": "Animated Stats Bar",
"category": "stats",
"dependencies": ["gsap", "gsap/ScrollTrigger"],
"html_template": "<section class=\"stats\">{% for stat in props.stats %}<div class=\"stat\"><span class=\"stat-value\" data-target=\"{{ stat.value }}\">0</span><span class=\"stat-label\">{{ stat.label }}</span></div>{% endfor %}</section>",
"css": ".stats { display: flex; justify-content: center; gap: 3rem; padding: 4rem 2rem; }\n.stat { text-align: center; }\n.stat-value { font-size: 3rem; font-weight: 700; color: var(--primary); display: block; }\n.stat-label { font-size: 0.875rem; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }",
"js": "gsap.registerPlugin(ScrollTrigger); root.querySelectorAll(\".stat-value\").forEach(el => { const target = parseInt(el.dataset.target, 10); gsap.fromTo(el, { textContent: 0 }, { textContent: target, duration: 2, ease: \"power2.out\", snap: { textContent: 1 }, scrollTrigger: { trigger: el, start: \"top 80%\" } }); });",
"props_schema": {
"type": "object",
"properties": {
"stats": {
"type": "array",
"items": {
"type": "object",
"properties": {
"value": { "type": "integer" },
"label": { "type": "string" }
}
}
}
},
"required": ["stats"]
}
}'
How CDN Libraries Load

Libraries declared in dependencies are loaded via <script> tags in the page's <head>. If multiple components on the same page use the same library, it's loaded only once (deduplicated). Libraries use SRI hashes when available for security.


Framework Components (Tier 4)

For complex interactive UIs — dashboards, configurators, multi-step forms — use React, Vue, or Svelte. You submit source code; SpiderPublish builds it server-side with esbuild into a self-contained Web Component.

How It Works

  1. Create the component with framework and source_code
  2. Publish — returns HTTP 202 (build is async)
  3. Poll GET .../build-status until build_status: "success"
  4. The built bundle is uploaded to R2 CDN at bundle_url
  5. On the live page, it renders as <spideriq-app-{slug}> with props passed via data-props

Example: React Interactive Pricing Toggle

curl -X POST "https://spideriq.ai/api/v1/dashboard/content/components" \
-H "Authorization: Bearer $CLIENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "pricing-toggle",
"name": "Pricing Toggle",
"category": "pricing",
"framework": "react",
"source_code": "import React, { useState } from \"react\";\n\nexport default function PricingToggle({ headline, plans }) {\n const [annual, setAnnual] = useState(false);\n return (\n <section style={{ padding: \"4rem 2rem\", textAlign: \"center\" }}>\n <h2 style={{ fontSize: \"2rem\", marginBottom: \"1rem\" }}>{headline}</h2>\n <button onClick={() => setAnnual(!annual)} style={{ padding: \"0.5rem 1.5rem\", borderRadius: \"2rem\", border: \"2px solid currentColor\", background: \"none\", color: \"inherit\", cursor: \"pointer\", marginBottom: \"2rem\" }}>\n {annual ? \"Annual (save 20%)\" : \"Monthly\"}\n </button>\n <div style={{ display: \"flex\", gap: \"2rem\", justifyContent: \"center\", flexWrap: \"wrap\" }}>\n {(plans || []).map((plan, i) => (\n <div key={i} style={{ border: \"1px solid #333\", borderRadius: \"1rem\", padding: \"2rem\", minWidth: \"250px\" }}>\n <h3>{plan.name}</h3>\n <p style={{ fontSize: \"2.5rem\", fontWeight: 700 }}>\n ${annual ? Math.round(plan.price * 0.8) : plan.price}<span style={{ fontSize: \"1rem\" }}>/mo</span>\n </p>\n <ul style={{ listStyle: \"none\", padding: 0 }}>\n {(plan.features || []).map((f, j) => <li key={j} style={{ padding: \"0.25rem 0\" }}>{f}</li>)}\n </ul>\n </div>\n ))}\n </div>\n </section>\n );\n}",
"props_schema": {
"type": "object",
"properties": {
"headline": { "type": "string" },
"plans": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"price": { "type": "number" },
"features": { "type": "array", "items": { "type": "string" } }
}
}
}
},
"required": ["headline", "plans"]
}
}'

Publish and Wait for Build

# Publish (returns 202 — build is async)
curl -X POST "https://spideriq.ai/api/v1/dashboard/content/components/{id}/publish" \
-H "Authorization: Bearer $CLIENT_TOKEN"

# Poll build status
curl "https://spideriq.ai/api/v1/dashboard/content/components/{id}/build-status" \
-H "Authorization: Bearer $CLIENT_TOKEN"
# Response: { "build_status": "building" }
# ... wait a few seconds ...
# Response: { "build_status": "success", "bundle_url": "https://media.cdn.spideriq.ai/components/..." }
Async Builds

Tier 4 publish returns HTTP 202, not 200. The build runs in the background. Always poll build-status before expecting the component to render on a live page. If the build fails, check build_error and use the rebuild endpoint after fixing the source.

Supported Frameworks

Frameworksource_code FormatExport
reactJSX (React 18+)export default function ComponentName(props)
vueVue SFC (.vue format)<template>, <script setup>, <style scoped>
svelteSvelte component<script>, HTML, <style>

Props Schema Design

The props_schema field uses JSON Schema to define what data your component accepts. Props are validated when a page block references the component.

Common Patterns

String with default:

{
"headline": { "type": "string", "title": "Headline", "default": "Welcome" }
}

Number with range:

{
"columns": { "type": "integer", "minimum": 1, "maximum": 6, "default": 3 }
}

Enum (select):

{
"style": { "type": "string", "enum": ["centered", "left", "split"], "default": "centered" }
}

Array of strings:

{
"features": {
"type": "array",
"items": { "type": "string" },
"minItems": 1,
"maxItems": 12
}
}

Nested object (repeater):

{
"testimonials": {
"type": "array",
"items": {
"type": "object",
"properties": {
"quote": { "type": "string" },
"author": { "type": "string" },
"role": { "type": "string" },
"avatar_url": { "type": "string", "format": "uri" }
},
"required": ["quote", "author"]
}
}
}

Props Merge Behavior

When a page block references a component:

  1. Block's props are merged with the component's default_props
  2. Block props override defaults (block wins)
  3. The merged result is validated against props_schema
  4. In Liquid templates: access via {{ props.headline }}, {% for item in props.items %}
  5. In JS (Tier 2): access via props.headline, props.items
  6. In frameworks (Tier 4): props passed as component props directly

Common Component Patterns

{
"slug": "image-carousel",
"dependencies": ["swiper"],
"html_template": "<div class=\"swiper\"><div class=\"swiper-wrapper\">{% for slide in props.slides %}<div class=\"swiper-slide\"><img src=\"{{ slide.image }}\" alt=\"{{ slide.alt }}\" /></div>{% endfor %}</div><div class=\"swiper-pagination\"></div></div>",
"css": ".swiper { width: 100%; } .swiper-slide img { width: 100%; height: 400px; object-fit: cover; border-radius: 0.5rem; }",
"js": "new Swiper(root.querySelector('.swiper'), { loop: true, pagination: { el: root.querySelector('.swiper-pagination'), clickable: true }, autoplay: { delay: 4000 } });"
}

Animated Counter (Tier 3)

{
"slug": "counter-row",
"dependencies": ["countup"],
"html_template": "<div class=\"counters\">{% for c in props.counters %}<div class=\"counter\"><span class=\"num\" data-target=\"{{ c.value }}\">0</span><span class=\"label\">{{ c.label }}</span></div>{% endfor %}</div>",
"css": ".counters { display: flex; gap: 3rem; justify-content: center; padding: 3rem; }\n.num { font-size: 3rem; font-weight: 700; color: var(--primary); display: block; }\n.label { font-size: 0.875rem; opacity: 0.7; }",
"js": "root.querySelectorAll('.num').forEach(el => { const cu = new countUp.CountUp(el, parseInt(el.dataset.target), { duration: 2.5 }); const obs = new IntersectionObserver(entries => { if (entries[0].isIntersecting) { cu.start(); obs.disconnect(); } }); obs.observe(el); });"
}

Pricing Toggle (Tier 2)

{
"slug": "pricing-simple-toggle",
"html_template": "<div class=\"pricing\"><button class=\"toggle\">Monthly</button><div class=\"plans\">{% for plan in props.plans %}<div class=\"plan\" data-monthly=\"{{ plan.monthly }}\" data-annual=\"{{ plan.annual }}\"><h3>{{ plan.name }}</h3><span class=\"price\">${{ plan.monthly }}/mo</span></div>{% endfor %}</div></div>",
"css": ".toggle { padding: 0.5rem 1.5rem; border: 2px solid var(--primary); background: none; color: inherit; border-radius: 2rem; cursor: pointer; margin-bottom: 2rem; }\n.plans { display: flex; gap: 2rem; justify-content: center; }\n.plan { border: 1px solid #333; border-radius: 1rem; padding: 2rem; min-width: 220px; text-align: center; }\n.price { font-size: 2rem; font-weight: 700; }",
"js": "let annual = false; const btn = root.querySelector('.toggle'); btn.addEventListener('click', () => { annual = !annual; btn.textContent = annual ? 'Annual (save 20%)' : 'Monthly'; root.querySelectorAll('.plan').forEach(p => { const price = annual ? p.dataset.annual : p.dataset.monthly; p.querySelector('.price').textContent = '$' + price + '/mo'; }); });"
}

Versioning and Publishing

Status Flow

draft → published → archived
  • draft — Component is editable, not visible on live pages
  • published — Locked for rendering, visible on live pages
  • archived — Hidden from listing, existing page references still render

Versioning

Components use semver (1.0.0, 1.1.0, 2.0.0). To create a new version, POST a new component with the same slug but a different version.

# List all versions of a component
curl "https://spideriq.ai/api/v1/dashboard/content/components/hero-gradient/versions" \
-H "Authorization: Bearer $CLIENT_TOKEN"

Pinning Versions in Page Blocks

{
"type": "component",
"component_slug": "hero-gradient",
"component_version": "1.0.0",
"props": { "headline": "Welcome" }
}

Omit component_version to always use the latest published version.


Using Components in Pages

The component Block Type

Add to any page's blocks array:

{
"id": "unique-block-id",
"type": "component",
"component_slug": "hero-gradient",
"component_version": "1.0.0",
"props": {
"headline": "Ship Faster with AI",
"subheadline": "The platform that turns your data into revenue."
}
}

Full Page Example

curl -X POST "https://spideriq.ai/api/v1/dashboard/content/pages" \
-H "Authorization: Bearer $CLIENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Home",
"slug": "home",
"template": "landing",
"blocks": [
{
"id": "hero-1",
"type": "component",
"component_slug": "hero-gradient",
"props": { "headline": "Welcome to Acme" }
},
{
"id": "stats-1",
"type": "component",
"component_slug": "stats-animated",
"props": {
"stats": [
{ "value": 500, "label": "Clients" },
{ "value": 99, "label": "Uptime %" },
{ "value": 24, "label": "Countries" }
]
}
},
{
"id": "faq-1",
"type": "component",
"component_slug": "faq-accordion",
"props": {
"items": [
{ "question": "How does it work?", "answer": "Sign up, connect your data, deploy." },
{ "question": "What's the pricing?", "answer": "Free tier available, $29/mo for pro." }
]
}
}
]
}'

Each component renders in its own isolated Shadow DOM on the page — no CSS conflicts, even when mixing components from different sources.