Layout Context Reference
Duct’s Static Site Generation (SSG) uses Nunjucks layouts to define the HTML structure around your page content. Layouts provide complete control over the HTML document while offering rich context variables for dynamic content generation.
Layout Architecture
Core Concepts
Layouts in Duct follow a template-based architecture where:
- Layouts define the HTML shell and structure using Nunjucks templating
- Pages provide metadata via
getPageMeta()
and content via components - Content pages (blog posts, markdown) have separate static and interactive content
- Regular pages render Duct components directly into the layout
- Context variables bridge layouts and page data
- Includes enable reusable layout components
Layout Resolution
When a page component exports getLayout()
, Duct resolves the layout file:
// In your page component
export function getLayout(): string {
return 'default.html' // Resolves to src/layouts/default.html
}
If no layout is specified, Duct uses default.html
as the fallback.
Page Types and Content Rendering
Regular Pages vs Content Pages
Duct handles two distinct types of pages with different rendering patterns:
Regular Pages
Regular pages render Duct components directly:
// pages/about/index.tsx
export function getPageMeta() {
return {
title: 'About Us',
description: 'Learn about our company'
}
}
const AboutPage: DuctPageComponent = ({ meta, path, env }) => {
return <AboutPageComponent />
}
Layout pattern for regular pages:
<!-- default.html -->
<body>
<div id="app">
{{ content | safe }} <!-- Duct component renders here -->
</div>
</body>
Content Pages (Blog Posts, Markdown)
Content pages require a special __content__.tsx
file that configures the content directory and processing:
// pages/blog/__content__.tsx
const BlogPost = ({ meta }: PageProps) => {
// Only render interactive components for the #app container
return <ThemeToggle />
}
export function getLayout(): string {
return 'post.html'
}
export function getPageMeta(): ContentMeta {
return {
title: 'Blog Post',
description: 'A blog post'
}
}
// Configure content directory (relative to project root)
export function getContentDir(): string {
return 'content/blog'
}
// Optional: Filter content (e.g., exclude drafts in production)
export function filterContent(meta: ContentMeta, path: string): boolean {
if (process.env.NODE_ENV === 'production' && meta.draft) {
return false
}
return true
}
// Optional: Transform metadata to add computed fields
export function transformMeta(meta: ContentMeta, path: string): ContentMeta {
// Calculate reading time
if (meta.content) {
const wordCount = meta.content.split(/\s+/).length
meta.readingTime = Math.ceil(wordCount / 200)
}
// Add slug from path
meta.slug = path.split('/').pop() || ''
return meta
}
export default BlogPost
Layout pattern for content pages:
<!-- post.html -->
<body>
<!-- Static content (pre-rendered markdown) -->
<div id="content">
<article class="prose">
{{ staticContent | safe }}
</article>
</div>
<!-- Interactive components -->
<div id="app">
{{ interactiveContent | safe }}
</div>
</body>
Context Variables and Data Flow
The page
Variable
The page
object is the primary data bridge between your application and layouts. It combines:
- Page metadata from
getPageMeta()
- Frontmatter data from markdown files
- System-generated data from Duct’s SSG process
Data Sources
From getPageMeta()
function:
export function getPageMeta() {
return {
title: 'My Page',
description: 'Page description',
author: 'John Doe',
customData: 'Any custom field'
}
}
From markdown frontmatter:
---
title: "Blog Post Title"
description: "Post excerpt"
date: "2023-12-01"
tags: ["tutorial", "duct"]
author: "Jane Smith"
readingTime: 5
---
# Blog content here...
System-generated fields:
page.canonicalUrl
- Full canonical URLpage.urlPath
- Relative URL pathpage.scripts
- Array of script paths to loadpage.inlineScript
- Inline JavaScript code
Usage in Layouts
All these data sources merge into the page
variable:
<title>{{ page.title | default('My Site') }}</title>
<meta name="description" content="{{ page.description }}">
<meta name="author" content="{{ page.author }}">
<!-- Blog-specific fields from frontmatter -->
{% if page.date %}
<time datetime="{{ page.date }}">
{{ page.date | date("MMMM D, YYYY") }}
</time>
{% endif %}
{% if page.tags %}
<div class="flex gap-2">
{% for tag in page.tags %}
<span class="badge">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
<!-- Custom fields -->
<meta property="custom:data" content="{{ page.customData }}">
Environment Context (env
)
Environment variables from your configuration:
<title>{{ page.title | default(env.siteName) }}</title>
<meta property="og:url" content="{{ env.siteUrl }}{{ path }}">
<meta property="og:site_name" content="{{ env.siteName }}">
Collections Context
Access to your content collections:
<!-- Blog post navigation -->
{% set allPosts = collections.blog | sort(true, false, 'meta.date') | reverse %}
<!-- Related content -->
{% for post in collections.blog %}
{% if post.meta.tags contains 'tutorial' %}
<a href="{{ post.urlPath }}">{{ post.meta.title }}</a>
{% endif %}
{% endfor %}
Passing Data to Layouts
Via Page Metadata
The most common way to pass data to layouts is through getPageMeta()
:
// pages/product/[id].tsx
export function getPageMeta() {
return {
title: 'Product Details',
description: 'View product information',
ogType: 'product',
canonicalUrl: 'https://mysite.com/product/123',
customData: {
productId: '123',
category: 'electronics'
}
}
}
<!-- Layout access -->
<meta property="og:type" content="{{ page.ogType }}">
<link rel="canonical" href="{{ page.canonicalUrl }}">
<meta name="product-id" content="{{ page.customData.productId }}">
Via Frontmatter (Content Pages)
For markdown-based content, use frontmatter:
---
title: "Advanced Duct Patterns"
description: "Learn advanced patterns for building with Duct"
date: "2023-12-01"
tags: ["advanced", "patterns"]
author: "Technical Team"
readingTime: 8
featured: true
series: "Duct Mastery"
level: "advanced"
---
Content here...
<!-- Layout usage -->
<article>
<header>
<h1>{{ page.title }}</h1>
{% if page.series %}
<div class="series-badge">Part of: {{ page.series }}</div>
{% endif %}
{% if page.level %}
<div class="difficulty-{{ page.level }}">{{ page.level | title }}</div>
{% endif %}
</header>
<div class="prose">
{{ staticContent | safe }}
</div>
</article>
Dynamic Route Data with Content Access
For non-content pages (like tag listings, blog indexes, etc.), getRoutes()
receives a content
parameter containing all content collections. This allows you to generate pages based on your content:
// pages/blog/tag/[tag].tsx - Tag listing page
export async function getRoutes(content?: Map<string, ContentFile[]>): Promise<Record<string, any>> {
const routes: Record<string, any> = {}
if (content) {
// Extract all unique tags from all content collections
const allTags = new Set<string>()
for (const [collectionName, files] of content) {
for (const file of files) {
if (file.meta.tags) {
file.meta.tags.forEach(tag => allTags.add(tag))
}
}
}
// Generate a route for each tag
for (const tag of allTags) {
routes[`/blog/tag/${tag.toLowerCase()}`] = {
title: `Posts tagged "${tag}"`,
description: `All blog posts tagged with ${tag}`,
tag: tag,
postsPerPage: 5
}
}
}
return routes
}
// pages/blog/page/[page].tsx - Blog pagination
export async function getRoutes(content?: Map<string, ContentFile[]>): Promise<Record<string, any>> {
const routes: Record<string, any> = {}
if (content) {
const blogPosts = content.get('blog') || []
const postsPerPage = 5
const totalPages = Math.ceil(blogPosts.length / postsPerPage)
// Generate pagination routes
for (let page = 2; page <= totalPages; page++) {
routes[`/blog/page/${page}`] = {
title: `Blog - Page ${page}`,
description: 'Latest blog posts',
currentPage: page,
postsPerPage: postsPerPage
}
}
}
return routes
}
<!-- Tag listing layout -->
<h1>Posts tagged "{{ page.tag }}"</h1>
<meta name="description" content="{{ page.description }}">
<!-- Blog pagination layout -->
<title>{{ page.title }}</title>
<div class="pagination-info">Page {{ page.currentPage }}</div>
Layout Patterns
Basic Layout Structure
Every layout should follow this fundamental structure:
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
{% include "_meta_tags.html" %}
<link rel="stylesheet" href="/src/styles/main.css">
</head>
<body>
<div id="app">
{{ content | safe }}
</div>
<!-- Scripts -->
{% if page.scripts %}
{% for script in page.scripts %}
<script type="module" src="{{ script }}"></script>
{% endfor %}
{% endif %}
</body>
</html>
Content Page Layout
For pages with markdown content and optional interactive components:
<!-- post.html -->
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
{% set ogType = 'article' %}
{% include "_meta_tags.html" %}
<link rel="stylesheet" href="/src/styles/main.css">
</head>
<body>
<!-- Static content container -->
<div id="content">
<article class="max-w-4xl mx-auto px-4 py-8">
<!-- Article header with frontmatter data -->
<header class="mb-8 pb-8 border-b">
<h1>{{ page.title }}</h1>
<div class="flex gap-4 text-sm opacity-70">
{% if page.date %}
<time datetime="{{ page.date }}">
{{ page.date | date("MMMM D, YYYY") }}
</time>
{% endif %}
{% if page.author %}
<span>By {{ page.author }}</span>
{% endif %}
{% if page.readingTime %}
<span>{{ page.readingTime }} min read</span>
{% endif %}
</div>
{% if page.tags %}
<div class="flex gap-2 mt-4">
{% for tag in page.tags %}
<a href="/blog/tag/{{ tag | slug }}" class="badge badge-primary">
{{ tag }}
</a>
{% endfor %}
</div>
{% endif %}
</header>
<!-- Markdown content -->
<div class="prose prose-lg max-w-none">
{{ staticContent | safe }}
</div>
</article>
</div>
<!-- Interactive components -->
<div id="app">
{{ interactiveContent | safe }}
</div>
<!-- Scripts -->
{% for script in page.scripts %}
<script type="module" src="{{ script }}"></script>
{% endfor %}
</body>
</html>
Hybrid Layout (Static + Interactive)
Some layouts combine static Nunjucks-generated content with interactive areas:
<!-- blog-listing.html -->
{% from "_macros.html" import listing %}
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
{% include "_meta_tags.html" %}
<link rel="stylesheet" href="/src/styles/main.css">
</head>
<body>
<!-- Static listing generated by Nunjucks -->
<div class="max-w-6xl mx-auto px-4 py-8">
{{ listing(collections.blog, page, '/blog') }}
</div>
<!-- Interactive features (search, filters, etc.) -->
<div id="app"></div>
<!-- Scripts -->
{% for script in page.scripts %}
<script type="module" src="{{ script }}"></script>
{% endfor %}
</body>
</html>
Nunjucks Features and Template Inheritance
Duct leverages the full power of Nunjucks templating. Key features include:
Template Inheritance
Create base layouts and extend them:
<!-- _base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>{% block title %}{{ env.siteName }}{% endblock %}</title>
{% block head %}{% endblock %}
</head>
<body>
<header>{% block header %}{% endblock %}</header>
<main>{% block content %}{% endblock %}</main>
<footer>{% block footer %}{% endblock %}</footer>
</body>
</html>
<!-- blog.html -->
{% extends "_base.html" %}
{% block title %}{{ page.title }} - {{ env.siteName }}{% endblock %}
{% block head %}
<meta property="og:type" content="article">
{% endblock %}
{% block header %}
{% include "_blog_header.html" %}
{% endblock %}
{% block content %}
<article>
{{ staticContent | safe }}
</article>
{% endblock %}
Includes for Reusable Components
Break layouts into reusable pieces:
<!-- _meta_tags.html -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page.title | default(env.siteName) }}</title>
<meta name="description" content="{{ page.description }}">
<!-- Open Graph -->
<meta property="og:type" content="{{ page.ogType | default('website') }}">
<meta property="og:title" content="{{ page.title }}">
<meta property="og:description" content="{{ page.description }}">
<meta property="og:url" content="{{ env.siteUrl }}{{ path }}">
<!-- _header.html -->
<header class="border-b bg-white sticky top-0">
<nav class="max-w-6xl mx-auto px-4 py-4">
<div class="flex justify-between items-center">
<a href="/" class="text-xl font-bold">{{ env.siteName }}</a>
<div class="hidden md:flex gap-6">
<a href="/" class="hover:text-blue-600">Home</a>
<a href="/blog" class="hover:text-blue-600">Blog</a>
<a href="/about" class="hover:text-blue-600">About</a>
</div>
</div>
</nav>
</header>
Macros for Complex Logic
Create reusable template functions:
<!-- _macros.html -->
{% macro listing(posts, page, baseUrl, breadcrumbs=null) %}
<!-- Pagination logic -->
{% set postsPerPage = page.postsPerPage or 10 %}
{% set currentPage = page.currentPage or 1 %}
{% set sortedPosts = posts | sort(attribute='meta.date', reverse=true) %}
{% set totalPosts = sortedPosts | length %}
{% set totalPages = (totalPosts / postsPerPage) | round(0, 'ceil') %}
<!-- Post grid -->
<div class="grid gap-8">
{% for post in sortedPosts %}
{% if loop.index0 >= startIndex and loop.index0 < endIndex %}
<article class="card">
<h2><a href="{{ post.urlPath }}">{{ post.meta.title }}</a></h2>
<p>{{ post.meta.description }}</p>
<time>{{ post.meta.date | date("MMM D, YYYY") }}</time>
</article>
{% endif %}
{% endfor %}
</div>
<!-- Pagination -->
{% if totalPages > 1 %}
<nav class="flex justify-center mt-8">
{% for pageNum in range(1, totalPages + 1) %}
{% if pageNum == currentPage %}
<span class="btn btn-active">{{ pageNum }}</span>
{% else %}
<a href="{{ baseUrl }}{% if pageNum > 1 %}/page/{{ pageNum }}{% endif %}" class="btn">
{{ pageNum }}
</a>
{% endif %}
{% endfor %}
</nav>
{% endif %}
{% endmacro %}
Advanced Nunjucks Features
- Filters: Transform data (
{{ page.date | date("YYYY-MM-DD") }}
) - Tests: Conditional logic (
{% if post.featured is defined %}
) - Loops: Iterate with control (
{% for post in posts %} ... {% endfor %}
) - Conditionals: Complex branching (
{% if page.ogType == 'article' %}
) - Variables: Set and manipulate data (
{% set currentIndex = loop.index0 %}
)
For comprehensive documentation on Nunjucks features, see the official Nunjucks documentation.
Best Practices
- Understand page types - Use appropriate rendering patterns for regular vs content pages
- Leverage data flow - Pass data through
getPageMeta()
and frontmatter effectively - Use includes - Break layouts into reusable components
- Consider inheritance - Use template inheritance for consistent structure
- Optimize performance - Load scripts and styles efficiently
- Plan for SEO - Include comprehensive metadata
- Support themes - Use data attributes for theme switching
- Validate markup - Ensure proper HTML structure
Layouts are the foundation of your Duct application’s presentation layer. By understanding the data flow from page metadata and frontmatter to layout variables, and leveraging Nunjucks’ powerful templating features, you can create flexible, maintainable, and performant website architectures.