Skip to main content
Page vs HTML Member: Use Page for complete web pages with routing, SEO, and hydration (e.g., dashboards, landing pages). Use HTML for fragments and templates (e.g., email templates, PDF content, reusable components). See HTML member for template-only use cases.

Overview

The Page member type enables you to build complete web applications with automatic routing, server-side rendering, SEO optimization, and client-side interactivity. Think of it as a mini web server that turns your Conductor worker into a full-featured application. Key Features:
  • 🎯 Automatic Routing - Convention-based routes with zero configuration
  • Server-Side Rendering - Fast initial page loads
  • 🔄 Client Hydration - htmx, progressive enhancement, or islands architecture
  • 🔍 SEO Optimized - Built-in meta tags, Open Graph, JSON-LD support
  • 🔐 Auth & Security - Route-level authentication and rate limiting
  • 💾 Smart Caching - Edge caching with flexible invalidation
  • 🎨 Layout System - Reusable layouts across pages

Basic Usage

Simple Page

Create a page at pages/about/page.yaml:
name: about
type: Page
description: About page

config:
  # No route config needed - automatically serves at /about

  renderMode: ssr

  component: |
    <div class="about-page">
      <h1>About {{company}}</h1>
      <p>{{description}}</p>
    </div>

input:
  company: Acme Corp
  description: We build amazing things
That’s it! This page is now automatically available at /about.

Homepage (Index)

Create pages/index/page.yaml for your homepage:
name: index
type: Page

config:
  component: |
    <div class="hero">
      <h1>Welcome to {{siteName}}</h1>
      <a href="/login" class="cta-button">Get Started</a>
    </div>

input:
  siteName: My App
The page named index automatically routes to /.

Routing

Convention-Based Routing

Pages automatically get routes based on their names:
Page NameRouteExample URL
index/yoursite.com/
about/aboutyoursite.com/about
dashboard/dashboardyoursite.com/dashboard
blog-post/blog-postyoursite.com/blog-post

Dynamic Routes

Use :param syntax for dynamic segments:
name: blog-post
type: Page

config:
  route:
    path: /blog/:slug
    methods: [GET]

  component: |
    <article>
      <h1>{{post.title}}</h1>
      <div>{{{post.content}}}</div>
    </article>
Access parameters via $input.slug:
input:
  # The :slug parameter is automatically available
  # Example: /blog/hello-world → slug = "hello-world"
  post:
    title: "Post about {{slug}}"

Explicit Route Configuration

Override conventions with explicit routes:
config:
  route:
    path: /dashboard
    methods: [GET, POST]
    aliases: ["/admin", "/panel"]  # Also respond to these paths
    auth: required  # Requires authentication
    rateLimit:
      requests: 100
      window: 60  # 100 requests per 60 seconds
Auth Options:
  • none - Public page (default)
  • required - Must be authenticated
  • optional - Auth optional but user data available

Render Modes

Server-Side Rendering (SSR)

Default mode - renders HTML on the server:
config:
  renderMode: ssr

  component: |
    <div>
      <h1>{{title}}</h1>
      <p>Rendered on: {{serverTime}}</p>
    </div>

input:
  title: Dashboard
  serverTime: ${new Date().toISOString()}

Static Rendering

For pages that don’t change often:
config:
  renderMode: static

  cache:
    enabled: true
    ttl: 3600  # Cache for 1 hour

Hybrid Rendering

SSR with client-side hydration for interactivity:
config:
  renderMode: hybrid

  hydration:
    strategy: htmx

Client-Side Hydration

Add interactive features with minimal JavaScript:
config:
  hydration:
    strategy: htmx
    htmx:
      enabled: true
      version: "1.9.10"
      extensions: ["json-enc"]

  component: |
    <div>
      <button hx-post="/api/like" hx-target="#likes">
        ❤️ Like
      </button>
      <span id="likes">{{likes}} likes</span>
    </div>
Built-in htmx Patterns:
  • hx-get - Load content from URL
  • hx-post - Submit forms
  • hx-target - Where to put the response
  • hx-swap - How to swap content
  • hx-trigger - When to trigger request

Progressive Enhancement

Enhance forms and links automatically:
config:
  hydration:
    strategy: progressive
    progressive:
      enhanceForms: true
      enhanceLinks: true

Islands Architecture

Hydrate specific components only:
config:
  hydration:
    strategy: islands
    islands:
      - id: user-menu
        component: UserMenu
        loadOn: interaction
        priority: high

      - id: analytics-chart
        component: Chart
        loadOn: visible
        priority: low

No Hydration

For static content only:
config:
  hydration:
    strategy: none

SEO Configuration

Basic SEO

config:
  seo:
    title: About Us | Acme Corp
    description: Learn more about Acme Corp and our mission
    canonical: /about
    robots: index, follow

Open Graph (Social Sharing)

config:
  seo:
    title: Amazing Product Launch
    description: We're launching something incredible
    canonical: /products/new-launch

  head:
    og:
      title: Amazing Product Launch
      description: Check out our new product!
      image: https://yoursite.com/og-image.jpg
      type: website
      url: https://yoursite.com/products/new-launch

    twitter:
      card: summary_large_image
      site: "@yourcompany"
      title: Amazing Product Launch
      description: Check out our new product!
      image: https://yoursite.com/twitter-card.jpg

Structured Data (JSON-LD)

config:
  seo:
    jsonLd:
      - "@context": "https://schema.org"
        "@type": "Product"
        name: "{{product.name}}"
        image: "{{product.image}}"
        description: "{{product.description}}"
        offers:
          "@type": "Offer"
          price: "{{product.price}}"
          priceCurrency: "USD"

Layouts

Reuse layouts across multiple pages:
config:
  layout:
    name: main
    props:
      sidebar: true
      header: true
      footer: true

  component: |
    <!-- Page content goes here -->
    <h1>{{title}}</h1>
    <p>{{content}}</p>
The layout wraps your page content automatically.

Caching

Enable Edge Caching

config:
  cache:
    enabled: true
    ttl: 1800  # 30 minutes
    vary: ["Cookie"]  # Vary by Cookie header
    staleWhileRevalidate: 300  # Serve stale for 5 min while revalidating

Cache by User

config:
  cache:
    enabled: true
    ttl: 300
    vary: ["Cookie", "Authorization"]

Disable Caching

For user-specific or real-time pages:
config:
  cache:
    enabled: false

Styling

Inline Styles

config:
  head:
    styles:
      - |
        body {
          font-family: system-ui, sans-serif;
          margin: 0;
          padding: 0;
        }
        .hero {
          min-height: 100vh;
          background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        }

External Stylesheets

config:
  head:
    links:
      - rel: stylesheet
        href: https://cdn.jsdelivr.net/npm/tailwindcss@3/dist/tailwind.min.css

      - rel: stylesheet
        href: /assets/custom.css

Complete Example

A full-featured dashboard page:
name: dashboard
type: Page
description: User analytics dashboard

config:
  # Routing
  route:
    path: /dashboard
    methods: [GET]
    auth: required
    rateLimit:
      requests: 100
      window: 60

  # Rendering
  renderMode: ssr

  # Component
  component: |
    <div class="dashboard">
      <header>
        <h1>Welcome back, {{user.name}}!</h1>
      </header>

      <div class="stats-grid">
        {{#each metrics}}
        <div class="stat-card">
          <h3>{{this.label}}</h3>
          <p class="stat-value">{{this.value}}</p>
          <span class="stat-change {{this.trend}}">{{this.change}}</span>
        </div>
        {{/each}}
      </div>

      <div class="chart-container"
           hx-get="/api/chart-data"
           hx-trigger="load"
           hx-swap="innerHTML">
        Loading chart...
      </div>
    </div>

  # Hydration
  hydration:
    strategy: htmx
    htmx:
      enabled: true
      version: "1.9.10"

  # SEO
  seo:
    title: Dashboard
    description: Your analytics dashboard
    canonical: /dashboard
    robots: noindex, nofollow  # Don't index authenticated pages

  # Caching
  cache:
    enabled: true
    ttl: 300
    vary: ["Cookie"]

  # Styles
  head:
    meta:
      - name: viewport
        content: width=device-width, initial-scale=1

    links:
      - rel: stylesheet
        href: /assets/dashboard.css

    styles:
      - |
        .dashboard {
          max-width: 1200px;
          margin: 0 auto;
          padding: 2rem;
        }
        .stats-grid {
          display: grid;
          grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
          gap: 1.5rem;
          margin: 2rem 0;
        }
        .stat-card {
          background: white;
          padding: 1.5rem;
          border-radius: 8px;
          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }

# Page input
input:
  user:
    name: John Doe
    email: john@example.com

  metrics:
    - label: Total Users
      value: "1,234"
      change: "+12%"
      trend: "up"
    - label: Revenue
      value: "$45,678"
      change: "+8%"
      trend: "up"
    - label: Conversion Rate
      value: "3.2%"
      change: "-2%"
      trend: "down"

Integration with Worker

The PageRouter automatically handles all your pages:
import { PageRouter } from '@ensemble-edge/conductor';
import { PageMember } from '@ensemble-edge/conductor/members/page';

// Import page configs
import indexConfig from '../pages/index/page.yaml';
import dashboardConfig from '../pages/dashboard/page.yaml';

// Initialize router
const pageRouter = new PageRouter({
  indexFiles: ['index'],
  baseUrl: ''
});

// Register pages
const pagesMap = new Map([
  ['index', { config: indexConfig, member: new PageMember(indexConfig) }],
  ['dashboard', { config: dashboardConfig, member: new PageMember(dashboardConfig) }]
]);

// Auto-discover and register routes
await pageRouter.discoverPages(pagesMap);

// In your fetch handler
export default {
  async fetch(request, env, ctx) {
    // Try page routing
    const pageResponse = await pageRouter.handle(request, env, ctx);
    if (pageResponse) {
      return pageResponse;
    }

    // Other handlers...
    return new Response('Not Found', { status: 404 });
  }
};

Best Practices

1. Use Convention-Based Routing

Let the framework handle routing for simple pages:
# pages/about/page.yaml - automatically routes to /about
name: about
type: Page

2. Enable Caching for Static Pages

config:
  cache:
    enabled: true
    ttl: 3600

3. Use htmx for Interactivity

Avoid heavy JavaScript frameworks:
config:
  hydration:
    strategy: htmx

4. Optimize SEO

Always provide title, description, and meta tags:
config:
  seo:
    title: Your Page Title
    description: Clear description under 160 characters

5. Disable Caching for User Pages

config:
  cache:
    enabled: false  # For authenticated pages

6. Use Dynamic Routes

For content-driven pages:
config:
  route:
    path: /blog/:slug

Default Error Pages

Conductor includes beautifully designed error pages that you can customize. When you initialize a new Conductor project, you get four default error pages:

Included Error Pages

  • 401 Unauthorized - /errors/401 - Shown when authentication is required
  • 403 Forbidden - /errors/403 - Shown when user lacks permissions
  • 404 Not Found - /errors/404 - Shown when page doesn’t exist
  • 500 Internal Server Error - /errors/500 - Shown when server errors occur

Customizing Error Pages

All error pages are located in pages/errors/ and can be fully customized:
# pages/errors/404/page.yaml
name: error-404
type: page
description: 404 Not Found error page

# Route configuration
route:
  path: /errors/404
  methods: [GET]
  auth:
    requirement: public  # Error pages should be public
  headers:
    X-Content-Type-Options: nosniff
    X-Frame-Options: DENY
    X-XSS-Protection: "1; mode=block"

# Page configuration
renderMode: ssr

component: |
    <div class="error-page">
      <div class="error-content">
        <div class="error-code">404</div>
        <h1>Page Not Found</h1>
        <p>The page you're looking for doesn't exist.</p>
        <a href="/">Go Home</a>
      </div>
    </div>

seo:
  title: "404 - Page Not Found"
  robots: noindex, nofollow

cache:
  enabled: true
  ttl: 3600

Customization Options

1. Update the design:
component: |
  <div class="custom-error-page">
    <img src="/logo.png" alt="Logo" />
    <h1>Oops! {{errorCode}}</h1>
    <p>{{message}}</p>
    <a href="/">Return Home</a>
  </div>
2. Add custom headers:
route:
  headers:
    X-Custom-Header: "Custom Value"
    X-Error-ID: "{{errorId}}"
3. Add search functionality:
input:
  searchEnabled: true
  helpfulLinks:
    - title: Documentation
      url: /docs
    - title: Support
      url: /support
4. Customize for different error codes:
# 401 - Add authentication prompt
route:
  headers:
    WWW-Authenticate: 'Bearer realm="API"'

# 403 - Show permission requirements
component: |
  <p>You need <strong>{{requiredRole}}</strong> permissions.</p>

# 500 - Add error tracking
component: |
  <p>Error ID: <code>{{errorId}}</code></p>
  <p>Please reference this ID when contacting support.</p>

Using Error Pages in Routes

Reference error pages in your route failure handlers:
# pages/admin/dashboard/page.yaml
route:
  path: /admin/dashboard
  auth:
    requirement: required
    roles: [admin]
    onFailure:
      action: page
      page: error-403  # Use the 403 error page
      context:
        message: "Admin access required"
        requiredRole: "admin"

Error Page Best Practices

  1. Keep them public - Error pages should have auth: public
  2. Add security headers - Include X-Frame-Options, X-Content-Type-Options
  3. Cache 404s - Enable caching for 404 pages to reduce load
  4. Don’t cache 500s - Disable caching for server errors
  5. Provide helpful actions - Include links to home, search, or support
  6. Brand consistently - Match your application’s design system
  7. Add analytics - Track error pages to identify issues

Error Page Routing

Error pages use standard page routing with security headers:
route:
  path: /errors/404  # Standard error page path
  methods: [GET]
  auth:
    requirement: public  # Always public
  headers:
    X-Content-Type-Options: nosniff  # Security
    X-Frame-Options: DENY            # Prevent clickjacking
    X-XSS-Protection: "1; mode=block"  # XSS protection

Page vs HTML Member

FeaturePageHTML
Use CaseFull web pagesTemplates & fragments
Routing✅ Automatic❌ No routing
SEO✅ Full support❌ N/A
Hydration✅ Multiple strategies❌ N/A
Caching✅ Edge caching❌ N/A
Auth✅ Route-level❌ N/A
OutputFull HTML documentHTML fragment
Best ForDashboards, landing pagesEmails, PDFs, components

Configuration Reference

Route Config

route:
  path: string          # Route path (default: /{name})
  methods: string[]     # HTTP methods (default: [GET])
  aliases: string[]     # Alternative paths

  # Authentication
  auth:
    requirement: string  # public, optional, required
    methods: string[]    # bearer, apiKey, cookie, unkey, custom
    roles: string[]      # Required roles
    permissions: string[] # Required permissions
    onFailure:
      action: string     # redirect, page, json
      redirectTo: string # For redirect action
      page: string       # For page action

  # Response headers
  headers:
    X-Custom-Header: string  # Any custom headers

  # CORS configuration
  cors:
    origins: string[]    # Allowed origins or '*'
    methods: string[]    # Allowed HTTP methods
    credentials: boolean # Allow credentials
    maxAge: number       # Preflight cache (seconds)

  # Rate limiting
  rateLimit:
    requests: number    # Max requests
    window: number      # Time window in seconds
    keyBy: string       # user, ip, apiKey

  # Hooks
  beforeRender: string  # Hook function name
  priority: number      # Route priority (lower = higher)

Render Mode

  • ssr - Server-side rendering (default)
  • static - Static rendering
  • hybrid - SSR + hydration

Hydration Strategy

  • none - No client-side JavaScript
  • htmx - htmx-powered interactivity (recommended)
  • progressive - Progressive enhancement
  • islands - Islands architecture

Cache Config

cache:
  enabled: boolean           # Enable caching
  ttl: number               # Time to live (seconds)
  vary: string[]            # Vary by headers
  staleWhileRevalidate: number  # SWR duration