Build Hooks
Build hooks allow you to inject custom logic at various stages of the Stati build process. They provide powerful extension points for preprocessing content, integrating external services, generating additional files, and customizing the build pipeline.
Hook Lifecycle
Stati executes hooks in the following order:
1. beforeAll → Called once at build start
2. Content Discovery & Processing
3. beforeRender → Called for each page
4. Template Rendering
5. afterRender → Called for each page
6. Static Asset Copying
7. afterAll → Called once at build end
Hook Types
beforeAll
Called once before starting the build process.
Use cases:
- Initialize external services
- Fetch remote data
- Validate environment setup
- Generate build metadata
export default defineConfig({
hooks: {
beforeAll: async (ctx) => {
console.log(`Starting build with ${ctx.pages.length} pages`);
// Initialize external services
await initializeAnalytics();
// Validate environment
if (!process.env.API_KEY) {
throw new Error('API_KEY environment variable is required');
}
}
}
});
afterAll
Called once after completing the build process.
Use cases:
- Generate sitemaps and RSS feeds
- Deploy to CDN or hosting service
- Send build notifications
- Clean up temporary resources
export default defineConfig({
hooks: {
afterAll: async (ctx) => {
console.log(`Build complete! Generated ${ctx.pages.length} pages`);
// Generate sitemap
await generateSitemap(ctx.pages, ctx.config.site.baseUrl);
// Generate RSS feed
await generateRSSFeed(ctx.pages.filter(p => p.frontMatter.type === 'post'));
// Deploy to CDN
if (process.env.NODE_ENV === 'production') {
await deployToCDN(ctx.config.outDir);
}
// Send notification
await sendBuildNotification({
status: 'success',
pageCount: ctx.pages.length
});
}
}
});
beforeRender
Called before rendering each individual page.
Use cases:
- Add dynamic data to pages
- Calculate reading time or word count
- Inject build metadata
- Modify page content
export default defineConfig({
hooks: {
beforeRender: async (ctx) => {
// Add build timestamp
ctx.page.frontMatter.buildTime = new Date().toISOString();
// Calculate reading time
const wordCount = ctx.page.content.split(/\s+/).length;
ctx.page.frontMatter.readingTime = Math.ceil(wordCount / 200);
// Add custom metadata based on URL
if (ctx.page.url.startsWith('/blog/')) {
ctx.page.frontMatter.section = 'blog';
}
}
}
});
afterRender
Called after rendering each individual page.
Use cases:
- Post-process generated HTML
- Validate output
- Generate search indices
- Optimize images
export default defineConfig({
hooks: {
afterRender: async (ctx) => {
console.log(`Rendered page: ${ctx.page.url}`);
// Extract data for search index or analytics
const wordCount = ctx.page.content.split(/\s+/).length;
await trackPageMetrics({
url: ctx.page.url,
title: ctx.page.frontMatter.title,
wordCount: wordCount
});
}
}
});
Hook Context
Each hook receives a context object with different properties:
beforeAll and afterAll Context
interface BuildContext {
config: StatiConfig; // Build configuration
pages: PageModel[]; // All discovered pages
}
beforeRender and afterRender Context
interface PageContext {
page: PageModel; // Current page being processed
config: StatiConfig; // Build configuration
}
Advanced Hook Patterns
External State Management
Since hook contexts don’t support shared state, use module-level state or external storage for sharing data between hooks:
// Module-level state
let sharedBuildData = {};
export default defineConfig({
hooks: {
beforeAll: async (ctx) => {
// Fetch data once, store in module state
sharedBuildData = {
posts: await fetchBlogPosts(),
authors: await fetchAuthors(),
buildId: generateBuildId()
};
},
beforeRender: async (ctx) => {
// Access shared data from module state
ctx.page.frontMatter.buildId = sharedBuildData.buildId;
// Add page-specific processing
if (ctx.page.frontMatter.type === 'post') {
ctx.page.frontMatter.section = 'blog';
}
}
}
});
Conditional Hook Execution
export default defineConfig({
hooks: {
beforeRender: async (ctx) => {
// Only process blog posts
if (!ctx.page.url.startsWith('/blog/')) {
return;
}
// Add blog-specific data
ctx.page.frontMatter.category = extractCategory(ctx.page.url);
ctx.page.frontMatter.tags = normalizeTags(ctx.page.frontMatter.tags || []);
},
afterRender: async (ctx) => {
// Only log in production builds
if (process.env.NODE_ENV === 'production') {
console.log(`Built production page: ${ctx.page.url}`);
}
}
}
});
Error Handling in Hooks
let fallbackData = null;
export default defineConfig({
hooks: {
beforeAll: async (ctx) => {
try {
fallbackData = await fetchExternalData();
} catch (error) {
console.warn('Failed to fetch external data, using fallback:', error.message);
fallbackData = { fallback: true };
}
},
beforeRender: async (ctx) => {
try {
await processPageData(ctx.page);
} catch (error) {
console.error(`Failed to process page ${ctx.page.url}:`, error);
// Decide whether to continue or fail the build
if (error.critical) {
throw error; // Stop the build
}
// Otherwise continue with default data
}
}
}
});
Async Hook Patterns
let sharedData = {};
export default defineConfig({
hooks: {
beforeAll: async (ctx) => {
// Parallel data fetching
const [posts, authors, categories] = await Promise.all([
fetchPosts(),
fetchAuthors(),
fetchCategories()
]);
sharedData = { posts, authors, categories };
},
afterAll: async (ctx) => {
// Parallel deployment tasks
await Promise.all([
uploadToS3(ctx.config.outDir),
purgeCloudflareCache()
]);
}
}
});
Hook Utilities
Page Filtering Helpers
const isPost = (page) => page.frontMatter.type === 'post';
const isDraft = (page) => page.frontMatter.draft === true;
const isPublished = (page) => !isDraft(page);
const isBlogContent = (page) => page.url.startsWith('/blog/');
let publishedPosts = [];
export default defineConfig({
hooks: {
beforeAll: async (ctx) => {
publishedPosts = ctx.pages
.filter(isPost)
.filter(isPublished)
.sort((a, b) => new Date(b.frontMatter.date) - new Date(a.frontMatter.date));
},
beforeRender: async (ctx) => {
// Use the filtered posts in page hooks
if (ctx.page.url === '/blog/') {
ctx.page.frontMatter.recentPosts = publishedPosts.slice(0, 5);
}
}
}
});
Content Processing Helpers
function extractHeadings(html) {
const headings = [];
const regex = /<h([1-6])[^>]*>([^<]+)<\/h[1-6]>/gi;
let match;
while ((match = regex.exec(html)) !== null) {
headings.push({
level: parseInt(match[1]),
text: match[2].trim(),
id: slugify(match[2])
});
}
return headings;
}
function calculateReadingTime(content) {
const wordsPerMinute = 200;
const wordCount = content.split(/\s+/).length;
return Math.ceil(wordCount / wordsPerMinute);
}
Real-World Examples
Blog with Related Posts
let allPosts = [];
function intersection(arr1, arr2) {
return arr1.filter(item => arr2.includes(item));
}
export default defineConfig({
hooks: {
beforeAll: async (ctx) => {
// Store all posts for use in page hooks
allPosts = ctx.pages.filter(p => p.frontMatter.type === 'post');
},
beforeRender: async (ctx) => {
if (ctx.page.frontMatter.type !== 'post') return;
// Find related posts by tags
const currentTags = ctx.page.frontMatter.tags || [];
const relatedPosts = allPosts
.filter(p => p.url !== ctx.page.url)
.map(p => ({
...p,
score: intersection(currentTags, p.frontMatter.tags || []).length
}))
.filter(p => p.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 3);
ctx.page.frontMatter.relatedPosts = relatedPosts;
}
}
});
Build hooks provide powerful extensibility while maintaining Stati’s performance and simplicity. Use them to integrate external services, process content dynamically, and customize the build pipeline to match your specific needs.