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
- Run
npm run dev
for development with hot reloading - The Vite plugin automatically generates static pages in the background
- Your pages are served with client-side routing for fast navigation
- 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