Duct UI

Static Site Generation

Duct supports static site generation (SSG) with file-based routing, allowing you to build fast, SEO-friendly websites that can be deployed to CDNs like Cloudflare Pages.

Getting Started

1. Install the CLI Package

First, add the Duct CLI to your project:

npm install @duct-ui/cli --save-dev

2. Update Vite Configuration

Add the Duct SSG plugin to your vite.config.ts:

import { defineConfig } from 'vite'
import { ductSSGPlugin } from '@duct-ui/cli/vite-plugin'

export default defineConfig({
  plugins: [
    ductSSGPlugin()
  ],
  // ... other config
})

3. Create Directory Structure

Create the required directories for pages and layouts:

src/
├── pages/              # Page components
│   ├── index.tsx       # Home page (/)
│   ├── 404.tsx         # 404 error page (/404)
│   ├── contact.tsx     # Contact page (/contact)
│   ├── about/
│   │   └── index.tsx   # About page (/about)
│   └── blog/
│       ├── index.tsx   # Blog index (/blog)
│       └── [sub].tsx   # Dynamic blog posts (/blog/*)
└── layouts/            # HTML templates
    └── shell.html      # Main layout template

Page Components

Page components in Duct SSG return either:

  • Duct components (created with createBlueprint) - These are reinstantiated on the client with full logic binding and interactivity
  • Plain JSX - Rendered as static HTML with no client-side presence or interactivity

📝 Note: See real examples in the Duct demo source:pages/demos/index.tsx,pages/demos/[sub].tsx, andpages/404.tsx

Each page component must export specific functions:

// src/pages/about/index.tsx
import type { DuctPageComponent, PageProps } from '@duct-ui/router'
import AboutContent from '../../components/AboutContent' // A Duct component

export function getLayout(): string {
  return 'shell.html'  // Layout template to use
}

export function getPageContext(): Record<string, any> {
  return {
    title: 'About Us',
    description: 'Learn more about our company',
    openGraph: {
      title: 'About Us - My Site',
      description: 'Learn more about our company and mission',
      image: '/images/about-og.jpg'
    }
  }
}

// Example 1: Returning a Duct component (with client-side interactivity)
const AboutPage: DuctPageComponent = ({ meta, path, env }: PageProps) => {
  // AboutContent is created with createBlueprint and will be 
  // reinstantiated on the client with full logic binding
  return <AboutContent />
}

// Example 2: Returning plain JSX (static HTML only)
const StaticAboutPage: DuctPageComponent = ({ meta, path, env }: PageProps) => {
  // This JSX will be rendered as static HTML with no client-side presence
  return (
    <div class="container mx-auto px-4 py-8">
      <h1>About Us</h1>
      <p>This is static content with no interactivity.</p>
    </div>
  )
}

export default AboutPage // Use the interactive version

Route Types

Static Routes

Static routes are created using:

  • index.tsx - Maps to the directory path (e.g., /about/index.tsx/about)
  • Named files - Any .tsx file maps to its filename (e.g., 404.tsx/404, contact.tsx/contact)

Dynamic Routes

For dynamic routes, create a [sub].tsx file:

// src/pages/blog/[sub].tsx
export function getLayout(): string {
  return 'shell.html'
}

export function getPageContext(): Record<string, any> {
  return {
    title: 'Blog Post',
    description: 'Read our latest blog post'
  }
}

// Generate static paths at build time
export async function getRoutes(): Promise<Record<string, any>> {
  const posts = await fetchBlogPosts() // Your data fetching logic

  const routes: Record<string, any> = {}
  for (const post of posts) {
    routes[`/blog/${post.slug}`] = {
      title: `${post.title} - My Blog`,
      description: post.excerpt,
      openGraph: {
        title: post.title,
        description: post.excerpt,
        image: post.coverImage
      }
    }
  }

  return routes
}

// Page component that returns a Duct component
const BlogPostPage: DuctPageComponent = ({ meta, path, env }: PageProps) => {
  const slug = path.split('/').pop()
  const post = blogPosts.find(p => p.slug === slug)
  
  if (!post) {
    // Plain JSX - no client-side presence
    return <div>Post not found</div>
  }
  
  // BlogPost is a Duct component - will be interactive on client
  return <BlogPost post={post} />
}

export default BlogPostPage

Layout Templates

Layout templates use Nunjucks templating with access to page context:

<!-- src/layouts/shell.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <!-- Page metadata -->
  <title>{{ page.title }}</title>
  <meta name="description" content="{{ page.description }}">

  <!-- Open Graph tags -->
  {% if page.openGraph %}
  <meta property="og:title" content="{{ page.openGraph.title }}">
  <meta property="og:description" content="{{ page.openGraph.description }}">
  <meta property="og:image" content="{{ page.openGraph.image }}">
  <meta property="og:type" content="{{ page.openGraph.type or 'website' }}">
  {% endif %}

  <!-- Twitter Card tags -->
  {% if page.twitter %}
  <meta name="twitter:card" content="{{ page.twitter.card or 'summary' }}">
  <meta name="twitter:title" content="{{ page.twitter.title or page.title }}">
  <meta name="twitter:description" content="{{ page.twitter.description or page.description }}">
  <meta name="twitter:image" content="{{ page.twitter.image or page.openGraph.image }}">
  {% endif %}

  <!-- Additional meta tags -->
  {% if page.meta %}
    {% for key, value in page.meta %}
    <meta name="{{ key }}" content="{{ value }}">
    {% endfor %}
  {% endif %}

  <!-- Stylesheets -->
  {% if page.styles %}
    {% for style in page.styles %}
    <link rel="stylesheet" href="{{ style }}">
    {% endfor %}
  {% endif %}
</head>
<body>
  <!-- Your Duct component renders here -->
  <div id="app"></div>

  <!-- Scripts -->
  {% if page.scripts %}
    {% for script in page.scripts %}
    <script type="module" src="{{ script }}"></script>
    {% endfor %}
  {% endif %}
</body>
</html>

Configuration

Create an optional duct.config.js to customize paths:

// duct.config.js
export default {
  pagesDir: 'src/pages',      // Default: 'src/pages'
  layoutsDir: 'src/layouts',  // Default: 'src/layouts'
  env: {
    // Environment variables available in components
    API_URL: process.env.API_URL,
    SITE_NAME: 'My Website'
  }
}

Build Commands

Update your package.json scripts:

{
  "scripts": {
    "dev": "vite",
    "build": "node node_modules/@duct-ui/cli/dist/cli.js build",
    "preview": "vite preview"
  }
}

Development Workflow

  1. Run npm run dev for development with hot reloading
  2. The Vite plugin automatically generates static pages in the background
  3. Your pages are served with client-side routing for fast navigation
  4. Run npm run build to generate production static files

Advanced Features

Environment Variables

Access environment variables in your page components:

export default function HomePage({ env }: { env: Record<string, any> }) {
  return (
    <div>
      <h1>Welcome to {env.SITE_NAME}</h1>
      <p>API URL: {env.API_URL}</p>
    </div>
  )
}

Conditional Rendering

Different behavior for static vs. client-side rendering:

export default function MyPage() {
  const isSSG = typeof window === 'undefined'

  return (
    <div>
      <h1>My Page</h1>
      {!isSSG && (
        <div>This only renders on the client</div>
      )}
    </div>
  )
}

💡 Pro Tips

  • Return Duct components (created with createBlueprint) for interactive pages
  • Return plain JSX for purely static content that needs no client-side logic
  • Use descriptive page context for better SEO
  • Include Open Graph images for social media sharing
  • Keep static generation fast by minimizing data fetching in getRoutes()
  • Use environment variables for configuration that changes between environments
  • Study the demo pages source for real-world examples